diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index e69de29bb2..9fd6c03c72 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -0,0 +1,5 @@ +# Applied 120 line-length rule to all files: https://github.com/modelcontextprotocol/python-sdk/pull/856 +543961968c0634e93d919d509cce23a1d6a56c21 + +# Added 100% code coverage baseline with pragma comments: https://github.com/modelcontextprotocol/python-sdk/pull/1553 +89e9c43acf7e23cf766357d776ec1ce63ac2c58e diff --git a/.gitattribute b/.gitattribute new file mode 100644 index 0000000000..0ab3744850 --- /dev/null +++ b/.gitattribute @@ -0,0 +1,2 @@ +# Generated +uv.lock linguist-generated=true diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 0000000000..e52277a2a7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,55 @@ +name: 🐛 MCP Python SDK Bug +description: Report a bug or unexpected behavior in the MCP Python SDK +labels: ["need confirmation"] + +body: + - type: markdown + attributes: + value: Thank you for contributing to the MCP Python SDK! ✊ + + - type: checkboxes + id: checks + attributes: + label: Initial Checks + description: Just making sure you're using the latest version of MCP Python SDK. + options: + - label: I confirm that I'm using the latest version of MCP Python SDK + required: true + - label: I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue + required: true + + - type: textarea + id: description + attributes: + label: Description + description: | + Please explain what you're seeing and what you would expect to see. + + Please provide as much detail as possible to make understanding and solving your problem as quick as possible. 🙏 + validations: + required: true + + - type: textarea + id: example + attributes: + label: Example Code + description: > + If applicable, please add a self-contained, + [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) + demonstrating the bug. + + placeholder: | + from mcp.server.fastmcp import FastMCP + + ... + render: Python + + - type: textarea + id: version + attributes: + label: Python & MCP Python SDK + description: | + Which version of Python and MCP Python SDK are you using? + render: Text + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index dd84ea7824..0000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000000..3ba13e0cec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml new file mode 100644 index 0000000000..bec9b77b15 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -0,0 +1,29 @@ +name: 🚀 MCP Python SDK Feature Request +description: "Suggest a new feature for the MCP Python SDK" +labels: ["feature request"] + +body: + - type: markdown + attributes: + value: Thank you for contributing to the MCP Python SDK! ✊ + + - type: textarea + id: description + attributes: + label: Description + description: | + Please give as much detail as possible about the feature you would like to suggest. 🙏 + + You might like to add: + * A demo of how code might look when using the feature + * Your use case(s) for the feature + * Reference to other projects that have a similar feature + validations: + required: true + + - type: textarea + id: references + attributes: + label: References + description: | + Please add any links or references that might help us understand your feature request better. 📚 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index bbcbbe7d61..0000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/question.yaml b/.github/ISSUE_TEMPLATE/question.yaml new file mode 100644 index 0000000000..87a7894f12 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yaml @@ -0,0 +1,33 @@ +name: ❓ MCP Python SDK Question +description: "Ask a question about the MCP Python SDK" +labels: ["question"] + +body: + - type: markdown + attributes: + value: Thank you for reaching out to the MCP Python SDK community! We're here to help! 🤝 + + - type: textarea + id: question + attributes: + label: Question + description: | + Please provide as much detail as possible about your question. 🙏 + + You might like to include: + * Code snippets showing what you've tried + * Error messages you're encountering (if any) + * Expected vs actual behavior + * Your use case and what you're trying to achieve + validations: + required: true + + - type: textarea + id: context + attributes: + label: Additional Context + description: | + Please provide any additional context that might help us better understand your question, such as: + * Your MCP Python SDK version + * Your Python version + * Relevant configuration or environment details 📝 diff --git a/.github/workflows/check-lock.yml b/.github/workflows/check-lock.yml deleted file mode 100644 index 805b0f3ccc..0000000000 --- a/.github/workflows/check-lock.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Check uv.lock - -on: - pull_request: - paths: - - "pyproject.toml" - - "uv.lock" - push: - paths: - - "pyproject.toml" - - "uv.lock" - -jobs: - check-lock: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install uv - run: | - curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.cargo/bin" >> $GITHUB_PATH - - - name: Check uv.lock is up to date - run: uv lock --check diff --git a/.github/workflows/comment-on-release.yml b/.github/workflows/comment-on-release.yml new file mode 100644 index 0000000000..f8b1751e53 --- /dev/null +++ b/.github/workflows/comment-on-release.yml @@ -0,0 +1,149 @@ +name: Comment on PRs in Release + +on: + release: + types: [published] + +permissions: + pull-requests: write + contents: read + +jobs: + comment-on-prs: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get previous release + id: previous_release + uses: actions/github-script@v7 + with: + script: | + const currentTag = '${{ github.event.release.tag_name }}'; + + // Get all releases + const { data: releases } = await github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100 + }); + + // Find current release index + const currentIndex = releases.findIndex(r => r.tag_name === currentTag); + + if (currentIndex === -1) { + console.log('Current release not found in list'); + return null; + } + + // Get previous release (next in the list since they're sorted by date desc) + const previousRelease = releases[currentIndex + 1]; + + if (!previousRelease) { + console.log('No previous release found, this might be the first release'); + return null; + } + + console.log(`Found previous release: ${previousRelease.tag_name}`); + + return previousRelease.tag_name; + + - name: Get merged PRs between releases + id: get_prs + uses: actions/github-script@v7 + with: + script: | + const currentTag = '${{ github.event.release.tag_name }}'; + const previousTag = ${{ steps.previous_release.outputs.result }}; + + if (!previousTag) { + console.log('No previous release found, skipping'); + return []; + } + + console.log(`Finding PRs between ${previousTag} and ${currentTag}`); + + // Get commits between previous and current release + const comparison = await github.rest.repos.compareCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + base: previousTag, + head: currentTag + }); + + const commits = comparison.data.commits; + console.log(`Found ${commits.length} commits`); + + // Get PRs associated with each commit using GitHub API + const prNumbers = new Set(); + + for (const commit of commits) { + try { + const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: commit.sha + }); + + for (const pr of prs) { + if (pr.merged_at) { + prNumbers.add(pr.number); + console.log(`Found merged PR: #${pr.number}`); + } + } + } catch (error) { + console.log(`Failed to get PRs for commit ${commit.sha}: ${error.message}`); + } + } + + console.log(`Found ${prNumbers.size} merged PRs`); + return Array.from(prNumbers); + + - name: Comment on PRs + uses: actions/github-script@v7 + with: + script: | + const prNumbers = ${{ steps.get_prs.outputs.result }}; + const releaseTag = '${{ github.event.release.tag_name }}'; + const releaseUrl = '${{ github.event.release.html_url }}'; + + const comment = `This pull request is included in [${releaseTag}](${releaseUrl})`; + + let commentedCount = 0; + + for (const prNumber of prNumbers) { + try { + // Check if we've already commented on this PR for this release + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100 + }); + + const alreadyCommented = comments.some(c => + c.user.type === 'Bot' && c.body.includes(releaseTag) + ); + + if (alreadyCommented) { + console.log(`Skipping PR #${prNumber} - already commented for ${releaseTag}`); + continue; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: comment + }); + commentedCount++; + console.log(`Successfully commented on PR #${prNumber}`); + } catch (error) { + console.error(`Failed to comment on PR #${prNumber}:`, error.message); + } + } + + console.log(`Commented on ${commentedCount} of ${prNumbers.length} PRs`); diff --git a/.github/workflows/main-checks.yml b/.github/workflows/main-checks.yml index 6f38043cdd..e2b2a97a14 100644 --- a/.github/workflows/main-checks.yml +++ b/.github/workflows/main-checks.yml @@ -5,6 +5,7 @@ on: branches: - main - "v*.*.*" + - "v1.x" tags: - "v*.*.*" diff --git a/.github/workflows/publish-docs-manually.yml b/.github/workflows/publish-docs-manually.yml index e1c3954b12..befe44d31c 100644 --- a/.github/workflows/publish-docs-manually.yml +++ b/.github/workflows/publish-docs-manually.yml @@ -19,6 +19,7 @@ jobs: uses: astral-sh/setup-uv@v3 with: enable-cache: true + version: 0.9.5 - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - uses: actions/cache@v4 @@ -29,4 +30,4 @@ jobs: mkdocs-material- - run: uv sync --frozen --group docs - - run: uv run --no-sync mkdocs gh-deploy --force + - run: uv run --frozen --no-sync mkdocs gh-deploy --force diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 17edd0f3c1..59ede84172 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -16,6 +16,7 @@ jobs: uses: astral-sh/setup-uv@v3 with: enable-cache: true + version: 0.9.5 - name: Set up Python 3.12 run: uv python install 3.12 @@ -67,6 +68,7 @@ jobs: uses: astral-sh/setup-uv@v3 with: enable-cache: true + version: 0.9.5 - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - uses: actions/cache@v4 @@ -77,4 +79,4 @@ jobs: mkdocs-material- - run: uv sync --frozen --group docs - - run: uv run --no-sync mkdocs gh-deploy --force + - run: uv run --frozen --no-sync mkdocs gh-deploy --force diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 03c36a6913..531487db5a 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -3,55 +3,76 @@ name: Shared Checks on: workflow_call: +permissions: + contents: read + +env: + COLUMNS: 150 + jobs: - format: + pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - name: Install uv - uses: astral-sh/setup-uv@v3 + - uses: astral-sh/setup-uv@v7 with: enable-cache: true + version: 0.9.5 + - name: Install dependencies + run: uv sync --frozen --all-extras --python 3.10 - - name: Install the project - run: uv sync --frozen --all-extras --dev --python 3.12 + - uses: pre-commit/action@v3.0.1 + with: + extra_args: --all-files --verbose + env: + SKIP: no-commit-to-branch - - name: Run ruff format check - run: uv run --no-sync ruff check . + test: + name: test (${{ matrix.python-version }}, ${{ matrix.dep-resolution.name }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + continue-on-error: true + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + dep-resolution: + - name: lowest-direct + install-flags: "--upgrade --resolution lowest-direct" + - name: highest + install-flags: "--upgrade --resolution highest" + os: [ubuntu-latest, windows-latest] - typecheck: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v7 with: enable-cache: true + version: 0.9.5 - name: Install the project - run: uv sync --frozen --all-extras --dev --python 3.12 + run: uv sync ${{ matrix.dep-resolution.install-flags }} --all-extras --python ${{ matrix.python-version }} - - name: Run pyright - run: uv run --no-sync pyright + - name: Run pytest with coverage + run: | + uv run --frozen --no-sync coverage run -m pytest + uv run --frozen --no-sync coverage combine + uv run --frozen --no-sync coverage report - test: + readme-snippets: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] - steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - name: Install uv - uses: astral-sh/setup-uv@v3 + - uses: astral-sh/setup-uv@v7 with: enable-cache: true + version: 0.9.5 - - name: Install the project - run: uv sync --frozen --all-extras --dev --python ${{ matrix.python-version }} + - name: Install dependencies + run: uv sync --frozen --all-extras --python 3.10 - - name: Run pytest - run: uv run --no-sync pytest + - name: Check README snippets are up to date + run: uv run --frozen scripts/update_readme_snippets.py --check diff --git a/.gitignore b/.gitignore index fa269235ef..2478cac4b3 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +.ruff_cache/ cover/ # Translations @@ -88,7 +89,7 @@ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: -# .python-version +.python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. @@ -162,8 +163,12 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ # vscode .vscode/ +.windsurfrules **/CLAUDE.local.md + +# claude code +.claude/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 35e12261a7..c06b9028da 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,32 +1,51 @@ fail_fast: true repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: end-of-file-fixer + - repo: https://github.com/pre-commit/mirrors-prettier rev: v3.1.0 hooks: - id: prettier types_or: [yaml, json5] + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.45.0 + hooks: + - id: markdownlint + args: + [ + "--fix", + "--config", + "pyproject.toml", + "--configPointer", + "/tool/markdown/lint", + ] + types: [markdown] + - repo: local hooks: - id: ruff-format name: Ruff Format - entry: uv run ruff + entry: uv run --frozen ruff args: [format] language: system types: [python] pass_filenames: false - id: ruff name: Ruff - entry: uv run ruff + entry: uv run --frozen ruff args: ["check", "--fix", "--exit-non-zero-on-fix"] types: [python] language: system pass_filenames: false + exclude: ^README\.md$ - id: pyright name: pyright - entry: uv run pyright - args: [src] + entry: uv run --frozen pyright language: system types: [python] pass_filenames: false @@ -36,3 +55,9 @@ repos: language: system files: ^(pyproject\.toml|uv\.lock)$ pass_filenames: false + - id: readme-snippets + name: Check README snippets are up to date + entry: uv run --frozen python scripts/update_readme_snippets.py --check + language: system + files: ^(README\.md|examples/.*\.py|scripts/update_readme_snippets\.py)$ + pass_filenames: false diff --git a/CLAUDE.md b/CLAUDE.md index e95b75cd58..cc2d360602 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,25 +16,29 @@ This document contains critical information about working with this codebase. Fo - Public APIs must have docstrings - Functions must be focused and small - Follow existing patterns exactly - - Line length: 88 chars maximum + - Line length: 120 chars maximum 3. Testing Requirements - - Framework: `uv run pytest` + - Framework: `uv run --frozen pytest` - Async testing: use anyio, not asyncio - Coverage: test edge cases and errors - New features require tests - Bug fixes require regression tests - For commits fixing bugs or adding features based on user reports add: + ```bash git commit --trailer "Reported-by:" ``` + Where `` is the name of the user. - For commits related to a Github issue, add + ```bash git commit --trailer "Github-Issue:#" ``` + - NEVER ever mention a `co-authored-by` or similar aspects. In particular, never mention the tool used to create the commit message or PR. @@ -44,8 +48,6 @@ This document contains critical information about working with this codebase. Fo the problem it tries to solve, and how it is solved. Don't go into the specifics of the code unless it adds clarity. -- Always add `jerome3o-anthropic` and `jspahrsummers` as reviewer. - - NEVER ever mention a `co-authored-by` or similar aspects. In particular, never mention the tool used to create the commit message or PR. @@ -54,9 +56,9 @@ This document contains critical information about working with this codebase. Fo ## Code Formatting 1. Ruff - - Format: `uv run ruff format .` - - Check: `uv run ruff check .` - - Fix: `uv run ruff check . --fix` + - Format: `uv run --frozen ruff format .` + - Check: `uv run --frozen ruff check .` + - Fix: `uv run --frozen ruff check . --fix` - Critical issues: - Line length (88 chars) - Import sorting (I001) @@ -67,7 +69,7 @@ This document contains critical information about working with this codebase. Fo - Imports: split into multiple lines 2. Type Checking - - Tool: `uv run pyright` + - Tool: `uv run --frozen pyright` - Requirements: - Explicit None checks for Optional - Type narrowing for strings @@ -104,6 +106,10 @@ This document contains critical information about working with this codebase. Fo - Add None checks - Narrow string types - Match existing patterns + - Pytest: + - If the tests aren't finding the anyio pytest mark, try adding PYTEST_DISABLE_PLUGIN_AUTOLOAD="" + to the start of the pytest run command eg: + `PYTEST_DISABLE_PLUGIN_AUTOLOAD="" uv run --frozen pytest` 3. Best Practices - Check git status before commits @@ -112,3 +118,15 @@ This document contains critical information about working with this codebase. Fo - Follow existing patterns - Document public APIs - Test thoroughly + +## Exception Handling + +- **Always use `logger.exception()` instead of `logger.error()` when catching exceptions** + - Don't include the exception in the message: `logger.exception("Failed")` not `logger.exception(f"Failed: {e}")` +- **Catch specific exceptions** where possible: + - File ops: `except (OSError, PermissionError):` + - JSON: `except json.JSONDecodeError:` + - Network: `except (ConnectionError, TimeoutError):` +- **Only catch `Exception` for**: + - Top-level handlers that must not crash + - Cleanup blocks (log at debug level) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 05c32c6053..985a28566c 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -mcp-coc@anthropic.com. +. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the @@ -116,7 +116,7 @@ the community. This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. +. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). @@ -124,5 +124,5 @@ enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. +. Translations are available at +. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 929e5f5041..dd60f39ce5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,38 +9,65 @@ Thank you for your interest in contributing to the MCP Python SDK! This document 3. Fork the repository 4. Clone your fork: `git clone https://github.com/YOUR-USERNAME/python-sdk.git` 5. Install dependencies: + ```bash uv sync --frozen --all-extras --dev ``` +6. Set up pre-commit hooks: + +```bash +uv tool install pre-commit --with pre-commit-uv --force-reinstall +``` + ## Development Workflow 1. Choose the correct branch for your changes: - - For bug fixes to a released version: use the latest release branch (e.g. v1.1.x for 1.1.3) - - For new features: use the main branch (which will become the next minor/major version) - - If unsure, ask in an issue first + + | Change Type | Target Branch | Example | + |-------------|---------------|---------| + | New features, breaking changes | `main` | New APIs, refactors | + | Security fixes for v1 | `v1.x` | Critical patches | + | Bug fixes for v1 | `v1.x` | Non-breaking fixes | + + > **Note:** `main` is the v2 development branch. Breaking changes are welcome on `main`. The `v1.x` branch receives only security and critical bug fixes. 2. Create a new branch from your chosen base branch 3. Make your changes 4. Ensure tests pass: -```bash + +```bash uv run pytest ``` 5. Run type checking: + ```bash uv run pyright ``` 6. Run linting: + ```bash uv run ruff check . uv run ruff format . ``` -7. Submit a pull request to the same branch you branched from +7. Update README snippets if you modified example code: + +```bash +uv run scripts/update_readme_snippets.py +``` + +8. (Optional) Run pre-commit hooks on all files: + +```bash +pre-commit run --all-files +``` + +9. Submit a pull request to the same branch you branched from ## Code Style diff --git a/README.md b/README.md index 0ca039ae3a..e7a6e955b9 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ [![MIT licensed][mit-badge]][mit-url] [![Python Version][python-badge]][python-url] [![Documentation][docs-badge]][docs-url] +[![Protocol][protocol-badge]][protocol-url] [![Specification][spec-badge]][spec-url] -[![GitHub Discussions][discussions-badge]][discussions-url] @@ -27,20 +27,41 @@ - [Server](#server) - [Resources](#resources) - [Tools](#tools) + - [Structured Output](#structured-output) - [Prompts](#prompts) - [Images](#images) - [Context](#context) + - [Getting Context in Functions](#getting-context-in-functions) + - [Context Properties and Methods](#context-properties-and-methods) + - [Completions](#completions) + - [Elicitation](#elicitation) + - [Sampling](#sampling) + - [Logging and Notifications](#logging-and-notifications) + - [Authentication](#authentication) + - [FastMCP Properties](#fastmcp-properties) + - [Session Properties and Methods](#session-properties-and-methods) + - [Request Context Properties](#request-context-properties) - [Running Your Server](#running-your-server) - [Development Mode](#development-mode) - [Claude Desktop Integration](#claude-desktop-integration) - [Direct Execution](#direct-execution) + - [Streamable HTTP Transport](#streamable-http-transport) + - [CORS Configuration for Browser-Based Clients](#cors-configuration-for-browser-based-clients) - [Mounting to an Existing ASGI Server](#mounting-to-an-existing-asgi-server) - - [Examples](#examples) - - [Echo Server](#echo-server) - - [SQLite Explorer](#sqlite-explorer) + - [StreamableHTTP servers](#streamablehttp-servers) + - [Basic mounting](#basic-mounting) + - [Host-based routing](#host-based-routing) + - [Multiple servers with path configuration](#multiple-servers-with-path-configuration) + - [Path configuration at initialization](#path-configuration-at-initialization) + - [SSE servers](#sse-servers) - [Advanced Usage](#advanced-usage) - [Low-Level Server](#low-level-server) + - [Structured Output Support](#structured-output-support) + - [Pagination (Advanced)](#pagination-advanced) - [Writing MCP Clients](#writing-mcp-clients) + - [Client Display Utilities](#client-display-utilities) + - [OAuth Authentication for Clients](#oauth-authentication-for-clients) + - [Parsing Tool Results](#parsing-tool-results) - [MCP Primitives](#mcp-primitives) - [Server Capabilities](#server-capabilities) - [Documentation](#documentation) @@ -53,12 +74,12 @@ [mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE [python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg [python-url]: https://www.python.org/downloads/ -[docs-badge]: https://img.shields.io/badge/docs-modelcontextprotocol.io-blue.svg -[docs-url]: https://modelcontextprotocol.io +[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg +[docs-url]: https://modelcontextprotocol.github.io/python-sdk/ +[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg +[protocol-url]: https://modelcontextprotocol.io [spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg -[spec-url]: https://spec.modelcontextprotocol.io -[discussions-badge]: https://img.shields.io/github/discussions/modelcontextprotocol/python-sdk -[discussions-url]: https://github.com/modelcontextprotocol/python-sdk/discussions +[spec-url]: https://modelcontextprotocol.io/specification/latest ## Overview @@ -66,14 +87,14 @@ The Model Context Protocol allows applications to provide context for LLMs in a - Build MCP clients that can connect to any MCP server - Create MCP servers that expose resources, prompts and tools -- Use standard transports like stdio and SSE +- Use standard transports like stdio, SSE, and Streamable HTTP - Handle all MCP protocol messages and lifecycle events ## Installation ### Adding MCP to your python project -We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects. +We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects. If you haven't created a uv-managed project yet, create one: @@ -89,6 +110,7 @@ If you haven't created a uv-managed project yet, create one: ``` Alternatively, for projects using pip for dependencies: + ```bash pip install "mcp[cli]" ``` @@ -105,12 +127,19 @@ uv run mcp Let's create a simple MCP server that exposes a calculator tool and some data: + ```python -# server.py +""" +FastMCP quickstart example. + +Run from the repository root: + uv run examples/snippets/servers/fastmcp_quickstart.py +""" + from mcp.server.fastmcp import FastMCP # Create an MCP server -mcp = FastMCP("Demo") +mcp = FastMCP("Demo", json_response=True) # Add an addition tool @@ -125,18 +154,49 @@ def add(a: int, b: int) -> int: def get_greeting(name: str) -> str: """Get a personalized greeting""" return f"Hello, {name}!" + + +# Add a prompt +@mcp.prompt() +def greet_user(name: str, style: str = "friendly") -> str: + """Generate a greeting prompt""" + styles = { + "friendly": "Please write a warm, friendly greeting", + "formal": "Please write a formal, professional greeting", + "casual": "Please write a casual, relaxed greeting", + } + + return f"{styles.get(style, styles['friendly'])} for someone named {name}." + + +# Run with streamable HTTP transport +if __name__ == "__main__": + mcp.run(transport="streamable-http") +``` + +_Full example: [examples/snippets/servers/fastmcp_quickstart.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/fastmcp_quickstart.py)_ + + +You can install this server in [Claude Code](https://docs.claude.com/en/docs/claude-code/mcp) and interact with it right away. First, run the server: + +```bash +uv run --with mcp examples/snippets/servers/fastmcp_quickstart.py ``` -You can install this server in [Claude Desktop](https://claude.ai/download) and interact with it right away by running: +Then add it to Claude Code: + ```bash -mcp install server.py +claude mcp add --transport http my-server http://localhost:8000/mcp ``` -Alternatively, you can test it with the MCP Inspector: +Alternatively, you can test it with the MCP Inspector. Start the server as above, then in a separate terminal: + ```bash -mcp dev server.py +npx -y @modelcontextprotocol/inspector ``` +In the inspector UI, connect to `http://localhost:8000/mcp`. + ## What is MCP? The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. MCP servers can: @@ -152,31 +212,46 @@ The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you bui The FastMCP server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: + ```python -# Add lifespan support for startup/shutdown with strong typing -from contextlib import asynccontextmanager +"""Example showing lifespan support for startup/shutdown with strong typing.""" + from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from dataclasses import dataclass -from fake_database import Database # Replace with your actual DB type - from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession -# Create a named server -mcp = FastMCP("My App") -# Specify dependencies for deployment and development -mcp = FastMCP("My App", dependencies=["pandas", "numpy"]) +# Mock database class for example +class Database: + """Mock database class for example.""" + + @classmethod + async def connect(cls) -> "Database": + """Connect to database.""" + return cls() + + async def disconnect(self) -> None: + """Disconnect from database.""" + pass + + def query(self) -> str: + """Execute a query.""" + return "Query result" @dataclass class AppContext: + """Application context with typed dependencies.""" + db: Database @asynccontextmanager async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: - """Manage application lifecycle with type-safe context""" + """Manage application lifecycle with type-safe context.""" # Initialize on startup db = await Database.connect() try: @@ -192,391 +267,1909 @@ mcp = FastMCP("My App", lifespan=app_lifespan) # Access type-safe lifespan context in tools @mcp.tool() -def query_db(ctx: Context) -> str: - """Tool that uses initialized resources""" +def query_db(ctx: Context[ServerSession, AppContext]) -> str: + """Tool that uses initialized resources.""" db = ctx.request_context.lifespan_context.db return db.query() ``` +_Full example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ + + ### Resources Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects: + ```python from mcp.server.fastmcp import FastMCP -mcp = FastMCP("My App") +mcp = FastMCP(name="Resource Example") -@mcp.resource("config://app") -def get_config() -> str: - """Static configuration data""" - return "App configuration here" +@mcp.resource("file://documents/{name}") +def read_document(name: str) -> str: + """Read a document by name.""" + # This would normally read from disk + return f"Content of {name}" -@mcp.resource("users://{user_id}/profile") -def get_user_profile(user_id: str) -> str: - """Dynamic user data""" - return f"Profile data for user {user_id}" +@mcp.resource("config://settings") +def get_settings() -> str: + """Get application settings.""" + return """{ + "theme": "dark", + "language": "en", + "debug": false +}""" ``` +_Full example: [examples/snippets/servers/basic_resource.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_resource.py)_ + + ### Tools Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects: + ```python -import httpx from mcp.server.fastmcp import FastMCP -mcp = FastMCP("My App") +mcp = FastMCP(name="Tool Example") @mcp.tool() -def calculate_bmi(weight_kg: float, height_m: float) -> float: - """Calculate BMI given weight in kg and height in meters""" - return weight_kg / (height_m**2) +def sum(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b @mcp.tool() -async def fetch_weather(city: str) -> str: - """Fetch current weather for a city""" - async with httpx.AsyncClient() as client: - response = await client.get(f"https://api.weather.com/{city}") - return response.text +def get_weather(city: str, unit: str = "celsius") -> str: + """Get weather for a city.""" + # This would normally call a weather API + return f"Weather in {city}: 22degrees{unit[0].upper()}" ``` -### Prompts +_Full example: [examples/snippets/servers/basic_tool.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_tool.py)_ + -Prompts are reusable templates that help LLMs interact with your server effectively: +Tools can optionally receive a Context object by including a parameter with the `Context` type annotation. This context is automatically injected by the FastMCP framework and provides access to MCP capabilities: + ```python -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.prompts import base - -mcp = FastMCP("My App") +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +mcp = FastMCP(name="Progress Example") -@mcp.prompt() -def review_code(code: str) -> str: - return f"Please review this code:\n\n{code}" +@mcp.tool() +async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: + """Execute a task with progress updates.""" + await ctx.info(f"Starting: {task_name}") + + for i in range(steps): + progress = (i + 1) / steps + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Step {i + 1}/{steps}", + ) + await ctx.debug(f"Completed step {i + 1}") -@mcp.prompt() -def debug_error(error: str) -> list[base.Message]: - return [ - base.UserMessage("I'm seeing this error:"), - base.UserMessage(error), - base.AssistantMessage("I'll help debug that. What have you tried so far?"), - ] + return f"Task '{task_name}' completed" ``` -### Images +_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ + -FastMCP provides an `Image` class that automatically handles image data: +#### Structured Output -```python -from mcp.server.fastmcp import FastMCP, Image -from PIL import Image as PILImage +Tools will return structured results by default, if their return type +annotation is compatible. Otherwise, they will return unstructured results. -mcp = FastMCP("My App") +Structured output supports these return types: +- Pydantic models (BaseModel subclasses) +- TypedDicts +- Dataclasses and other classes with type hints +- `dict[str, T]` (where T is any JSON-serializable type) +- Primitive types (str, int, float, bool, bytes, None) - wrapped in `{"result": value}` +- Generic types (list, tuple, Union, Optional, etc.) - wrapped in `{"result": value}` -@mcp.tool() -def create_thumbnail(image_path: str) -> Image: - """Create a thumbnail from an image""" - img = PILImage.open(image_path) - img.thumbnail((100, 100)) - return Image(data=img.tobytes(), format="png") -``` +Classes without type hints cannot be serialized for structured output. Only +classes with properly annotated attributes will be converted to Pydantic models +for schema generation and validation. -### Context +Structured results are automatically validated against the output schema +generated from the annotation. This ensures the tool returns well-typed, +validated data that clients can easily process. -The Context object gives your tools and resources access to MCP capabilities: +**Note:** For backward compatibility, unstructured results are also +returned. Unstructured results are provided for backward compatibility +with previous versions of the MCP specification, and are quirks-compatible +with previous versions of FastMCP in the current version of the SDK. -```python -from mcp.server.fastmcp import FastMCP, Context +**Note:** In cases where a tool function's return type annotation +causes the tool to be classified as structured _and this is undesirable_, +the classification can be suppressed by passing `structured_output=False` +to the `@tool` decorator. -mcp = FastMCP("My App") +##### Advanced: Direct CallToolResult +For full control over tool responses including the `_meta` field (for passing data to client applications without exposing it to the model), you can return `CallToolResult` directly: -@mcp.tool() -async def long_task(files: list[str], ctx: Context) -> str: - """Process multiple files with progress tracking""" - for i, file in enumerate(files): - ctx.info(f"Processing {file}") - await ctx.report_progress(i, len(files)) - data, mime_type = await ctx.read_resource(f"file://{file}") - return "Processing complete" -``` + +```python +"""Example showing direct CallToolResult return for advanced control.""" -## Running Your Server +from typing import Annotated -### Development Mode +from pydantic import BaseModel -The fastest way to test and debug your server is with the MCP Inspector: +from mcp.server.fastmcp import FastMCP +from mcp.types import CallToolResult, TextContent -```bash -mcp dev server.py +mcp = FastMCP("CallToolResult Example") -# Add dependencies -mcp dev server.py --with pandas --with numpy -# Mount local code -mcp dev server.py --with-editable . -``` +class ValidationModel(BaseModel): + """Model for validating structured output.""" -### Claude Desktop Integration + status: str + data: dict[str, int] -Once your server is ready, install it in Claude Desktop: -```bash -mcp install server.py +@mcp.tool() +def advanced_tool() -> CallToolResult: + """Return CallToolResult directly for full control including _meta field.""" + return CallToolResult( + content=[TextContent(type="text", text="Response visible to the model")], + _meta={"hidden": "data for client applications only"}, + ) -# Custom name -mcp install server.py --name "My Analytics Server" -# Environment variables -mcp install server.py -v API_KEY=abc123 -v DB_URL=postgres://... -mcp install server.py -f .env +@mcp.tool() +def validated_tool() -> Annotated[CallToolResult, ValidationModel]: + """Return CallToolResult with structured output validation.""" + return CallToolResult( + content=[TextContent(type="text", text="Validated response")], + structuredContent={"status": "success", "data": {"result": 42}}, + _meta={"internal": "metadata"}, + ) + + +@mcp.tool() +def empty_result_tool() -> CallToolResult: + """For empty results, return CallToolResult with empty content.""" + return CallToolResult(content=[]) ``` -### Direct Execution +_Full example: [examples/snippets/servers/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_call_tool_result.py)_ + -For advanced scenarios like custom deployments: +**Important:** `CallToolResult` must always be returned (no `Optional` or `Union`). For empty results, use `CallToolResult(content=[])`. For optional simple types, use `str | None` without `CallToolResult`. + ```python +"""Example showing structured output with tools.""" + +from typing import TypedDict + +from pydantic import BaseModel, Field + from mcp.server.fastmcp import FastMCP -mcp = FastMCP("My App") +mcp = FastMCP("Structured Output Example") -if __name__ == "__main__": - mcp.run() -``` -Run it with: -```bash -python server.py -# or -mcp run server.py -``` +# Using Pydantic models for rich structured data +class WeatherData(BaseModel): + """Weather information structure.""" -### Mounting to an Existing ASGI Server + temperature: float = Field(description="Temperature in Celsius") + humidity: float = Field(description="Humidity percentage") + condition: str + wind_speed: float -You can mount the SSE server to an existing ASGI server using the `sse_app` method. This allows you to integrate the SSE server with other ASGI applications. -```python -from starlette.applications import Starlette -from starlette.routing import Mount, Host -from mcp.server.fastmcp import FastMCP +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Get weather for a city - returns structured data.""" + # Simulated weather data + return WeatherData( + temperature=22.5, + humidity=45.0, + condition="sunny", + wind_speed=5.2, + ) -mcp = FastMCP("My App") +# Using TypedDict for simpler structures +class LocationInfo(TypedDict): + latitude: float + longitude: float + name: str -# Mount the SSE server to the existing ASGI server -app = Starlette( - routes=[ - Mount('/', app=mcp.sse_app()), - ] -) -# or dynamically mount as host -app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) -``` +@mcp.tool() +def get_location(address: str) -> LocationInfo: + """Get location coordinates""" + return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK") -For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). -## Examples +# Using dict[str, Any] for flexible schemas +@mcp.tool() +def get_statistics(data_type: str) -> dict[str, float]: + """Get various statistics""" + return {"mean": 42.5, "median": 40.0, "std_dev": 5.2} -### Echo Server -A simple server demonstrating resources, tools, and prompts: +# Ordinary classes with type hints work for structured output +class UserProfile: + name: str + age: int + email: str | None = None + + def __init__(self, name: str, age: int, email: str | None = None): + self.name = name + self.age = age + self.email = email -```python -from mcp.server.fastmcp import FastMCP -mcp = FastMCP("Echo") +@mcp.tool() +def get_user(user_id: str) -> UserProfile: + """Get user profile - returns structured data""" + return UserProfile(name="Alice", age=30, email="alice@example.com") -@mcp.resource("echo://{message}") -def echo_resource(message: str) -> str: - """Echo a message as a resource""" - return f"Resource echo: {message}" +# Classes WITHOUT type hints cannot be used for structured output +class UntypedConfig: + def __init__(self, setting1, setting2): # type: ignore[reportMissingParameterType] + self.setting1 = setting1 + self.setting2 = setting2 @mcp.tool() -def echo_tool(message: str) -> str: - """Echo a message as a tool""" - return f"Tool echo: {message}" +def get_config() -> UntypedConfig: + """This returns unstructured output - no schema generated""" + return UntypedConfig("value1", "value2") -@mcp.prompt() -def echo_prompt(message: str) -> str: - """Create an echo prompt""" - return f"Please process this message: {message}" +# Lists and other types are wrapped automatically +@mcp.tool() +def list_cities() -> list[str]: + """Get a list of cities""" + return ["London", "Paris", "Tokyo"] + # Returns: {"result": ["London", "Paris", "Tokyo"]} + + +@mcp.tool() +def get_temperature(city: str) -> float: + """Get temperature as a simple float""" + return 22.5 + # Returns: {"result": 22.5} ``` -### SQLite Explorer +_Full example: [examples/snippets/servers/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/structured_output.py)_ + -A more complex example showing database integration: +### Prompts -```python -import sqlite3 +Prompts are reusable templates that help LLMs interact with your server effectively: + +```python from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.prompts import base -mcp = FastMCP("SQLite Explorer") +mcp = FastMCP(name="Prompt Example") -@mcp.resource("schema://main") -def get_schema() -> str: - """Provide the database schema as a resource""" - conn = sqlite3.connect("database.db") - schema = conn.execute("SELECT sql FROM sqlite_master WHERE type='table'").fetchall() - return "\n".join(sql[0] for sql in schema if sql[0]) +@mcp.prompt(title="Code Review") +def review_code(code: str) -> str: + return f"Please review this code:\n\n{code}" -@mcp.tool() -def query_data(sql: str) -> str: - """Execute SQL queries safely""" - conn = sqlite3.connect("database.db") - try: - result = conn.execute(sql).fetchall() - return "\n".join(str(row) for row in result) - except Exception as e: - return f"Error: {str(e)}" +@mcp.prompt(title="Debug Assistant") +def debug_error(error: str) -> list[base.Message]: + return [ + base.UserMessage("I'm seeing this error:"), + base.UserMessage(error), + base.AssistantMessage("I'll help debug that. What have you tried so far?"), + ] ``` -## Advanced Usage +_Full example: [examples/snippets/servers/basic_prompt.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_prompt.py)_ + -### Low-Level Server +### Icons -For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server, including lifecycle management through the lifespan API: +MCP servers can provide icons for UI display. Icons can be added to the server implementation, tools, resources, and prompts: ```python -from contextlib import asynccontextmanager -from collections.abc import AsyncIterator +from mcp.server.fastmcp import FastMCP, Icon -from fake_database import Database # Replace with your actual DB type +# Create an icon from a file path or URL +icon = Icon( + src="icon.png", + mimeType="image/png", + sizes="64x64" +) -from mcp.server import Server +# Add icons to server +mcp = FastMCP( + "My Server", + website_url="https://example.com", + icons=[icon] +) +# Add icons to tools, resources, and prompts +@mcp.tool(icons=[icon]) +def my_tool(): + """Tool with an icon.""" + return "result" -@asynccontextmanager -async def server_lifespan(server: Server) -> AsyncIterator[dict]: - """Manage server startup and shutdown lifecycle.""" - # Initialize resources on startup - db = await Database.connect() - try: - yield {"db": db} - finally: - # Clean up on shutdown - await db.disconnect() +@mcp.resource("demo://resource", icons=[icon]) +def my_resource(): + """Resource with an icon.""" + return "content" +``` +_Full example: [examples/fastmcp/icons_demo.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/fastmcp/icons_demo.py)_ -# Pass lifespan to server -server = Server("example-server", lifespan=server_lifespan) +### Images +FastMCP provides an `Image` class that automatically handles image data: -# Access lifespan context in handlers -@server.call_tool() -async def query_db(name: str, arguments: dict) -> list: - ctx = server.request_context - db = ctx.lifespan_context["db"] - return await db.query(arguments["query"]) + +```python +"""Example showing image handling with FastMCP.""" + +from PIL import Image as PILImage + +from mcp.server.fastmcp import FastMCP, Image + +mcp = FastMCP("Image Example") + + +@mcp.tool() +def create_thumbnail(image_path: str) -> Image: + """Create a thumbnail from an image""" + img = PILImage.open(image_path) + img.thumbnail((100, 100)) + return Image(data=img.tobytes(), format="png") ``` -The lifespan API provides: -- A way to initialize resources when the server starts and clean them up when it stops -- Access to initialized resources through the request context in handlers -- Type-safe context passing between lifespan and request handlers +_Full example: [examples/snippets/servers/images.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/images.py)_ + -```python -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions +### Context -# Create a server instance -server = Server("example-server") +The Context object is automatically injected into tool and resource functions that request it via type hints. It provides access to MCP capabilities like logging, progress reporting, resource reading, user interaction, and request metadata. +#### Getting Context in Functions -@server.list_prompts() -async def handle_list_prompts() -> list[types.Prompt]: - return [ - types.Prompt( - name="example-prompt", - description="An example prompt template", - arguments=[ - types.PromptArgument( - name="arg1", description="Example argument", required=True - ) - ], - ) - ] +To use context in a tool or resource function, add a parameter with the `Context` type annotation: +```python +from mcp.server.fastmcp import Context, FastMCP -@server.get_prompt() -async def handle_get_prompt( - name: str, arguments: dict[str, str] | None -) -> types.GetPromptResult: - if name != "example-prompt": - raise ValueError(f"Unknown prompt: {name}") +mcp = FastMCP(name="Context Example") - return types.GetPromptResult( - description="Example prompt", - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent(type="text", text="Example prompt text"), - ) - ], - ) +@mcp.tool() +async def my_tool(x: int, ctx: Context) -> str: + """Tool that uses context capabilities.""" + # The context parameter can have any name as long as it's type-annotated + return await process_with_context(x, ctx) +``` -async def run(): - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) +#### Context Properties and Methods + +The Context object provides the following capabilities: + +- `ctx.request_id` - Unique ID for the current request +- `ctx.client_id` - Client ID if available +- `ctx.fastmcp` - Access to the FastMCP server instance (see [FastMCP Properties](#fastmcp-properties)) +- `ctx.session` - Access to the underlying session for advanced communication (see [Session Properties and Methods](#session-properties-and-methods)) +- `ctx.request_context` - Access to request-specific data and lifespan resources (see [Request Context Properties](#request-context-properties)) +- `await ctx.debug(message)` - Send debug log message +- `await ctx.info(message)` - Send info log message +- `await ctx.warning(message)` - Send warning log message +- `await ctx.error(message)` - Send error log message +- `await ctx.log(level, message, logger_name=None)` - Send log with custom level +- `await ctx.report_progress(progress, total=None, message=None)` - Report operation progress +- `await ctx.read_resource(uri)` - Read a resource by URI +- `await ctx.elicit(message, schema)` - Request additional information from user with validation + + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +mcp = FastMCP(name="Progress Example") -if __name__ == "__main__": - import asyncio - asyncio.run(run()) +@mcp.tool() +async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: + """Execute a task with progress updates.""" + await ctx.info(f"Starting: {task_name}") + + for i in range(steps): + progress = (i + 1) / steps + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Step {i + 1}/{steps}", + ) + await ctx.debug(f"Completed step {i + 1}") + + return f"Task '{task_name}' completed" ``` -### Writing MCP Clients +_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ + + +### Completions + +MCP supports providing completion suggestions for prompt arguments and resource template parameters. With the context parameter, servers can provide completions based on previously resolved values: -The SDK provides a high-level client interface for connecting to MCP servers: +Client usage: + ```python -from mcp import ClientSession, StdioServerParameters, types +""" +cd to the `examples/snippets` directory and run: + uv run completion-client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client +from mcp.types import PromptReference, ResourceTemplateReference # Create server parameters for stdio connection server_params = StdioServerParameters( - command="python", # Executable - args=["example_server.py"], # Optional command line arguments - env=None, # Optional environment variables + command="uv", # Using uv to run the server + args=["run", "server", "completion", "stdio"], # Server with completion support + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, ) -# Optional: create a sampling callback -async def handle_sampling_message( - message: types.CreateMessageRequestParams, -) -> types.CreateMessageResult: - return types.CreateMessageResult( - role="assistant", +async def run(): + """Run the completion client example.""" + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + + # List available resource templates + templates = await session.list_resource_templates() + print("Available resource templates:") + for template in templates.resourceTemplates: + print(f" - {template.uriTemplate}") + + # List available prompts + prompts = await session.list_prompts() + print("\nAvailable prompts:") + for prompt in prompts.prompts: + print(f" - {prompt.name}") + + # Complete resource template arguments + if templates.resourceTemplates: + template = templates.resourceTemplates[0] + print(f"\nCompleting arguments for resource template: {template.uriTemplate}") + + # Complete without context + result = await session.complete( + ref=ResourceTemplateReference(type="ref/resource", uri=template.uriTemplate), + argument={"name": "owner", "value": "model"}, + ) + print(f"Completions for 'owner' starting with 'model': {result.completion.values}") + + # Complete with context - repo suggestions based on owner + result = await session.complete( + ref=ResourceTemplateReference(type="ref/resource", uri=template.uriTemplate), + argument={"name": "repo", "value": ""}, + context_arguments={"owner": "modelcontextprotocol"}, + ) + print(f"Completions for 'repo' with owner='modelcontextprotocol': {result.completion.values}") + + # Complete prompt arguments + if prompts.prompts: + prompt_name = prompts.prompts[0].name + print(f"\nCompleting arguments for prompt: {prompt_name}") + + result = await session.complete( + ref=PromptReference(type="ref/prompt", name=prompt_name), + argument={"name": "style", "value": ""}, + ) + print(f"Completions for 'style' argument: {result.completion.values}") + + +def main(): + """Entry point for the completion client.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() +``` + +_Full example: [examples/snippets/clients/completion_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/completion_client.py)_ + +### Elicitation + +Request additional information from users. This example shows an Elicitation during a Tool Call: + + +```python +"""Elicitation examples demonstrating form and URL mode elicitation. + +Form mode elicitation collects structured, non-sensitive data through a schema. +URL mode elicitation directs users to external URLs for sensitive operations +like OAuth flows, credential collection, or payment processing. +""" + +import uuid + +from pydantic import BaseModel, Field + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +from mcp.shared.exceptions import UrlElicitationRequiredError +from mcp.types import ElicitRequestURLParams + +mcp = FastMCP(name="Elicitation Example") + + +class BookingPreferences(BaseModel): + """Schema for collecting user preferences.""" + + checkAlternative: bool = Field(description="Would you like to check another date?") + alternativeDate: str = Field( + default="2024-12-26", + description="Alternative date (YYYY-MM-DD)", + ) + + +@mcp.tool() +async def book_table(date: str, time: str, party_size: int, ctx: Context[ServerSession, None]) -> str: + """Book a table with date availability check. + + This demonstrates form mode elicitation for collecting non-sensitive user input. + """ + # Check if date is available + if date == "2024-12-25": + # Date unavailable - ask user for alternative + result = await ctx.elicit( + message=(f"No tables available for {party_size} on {date}. Would you like to try another date?"), + schema=BookingPreferences, + ) + + if result.action == "accept" and result.data: + if result.data.checkAlternative: + return f"[SUCCESS] Booked for {result.data.alternativeDate}" + return "[CANCELLED] No booking made" + return "[CANCELLED] Booking cancelled" + + # Date available + return f"[SUCCESS] Booked for {date} at {time}" + + +@mcp.tool() +async def secure_payment(amount: float, ctx: Context[ServerSession, None]) -> str: + """Process a secure payment requiring URL confirmation. + + This demonstrates URL mode elicitation using ctx.elicit_url() for + operations that require out-of-band user interaction. + """ + elicitation_id = str(uuid.uuid4()) + + result = await ctx.elicit_url( + message=f"Please confirm payment of ${amount:.2f}", + url=f"https://payments.example.com/confirm?amount={amount}&id={elicitation_id}", + elicitation_id=elicitation_id, + ) + + if result.action == "accept": + # In a real app, the payment confirmation would happen out-of-band + # and you'd verify the payment status from your backend + return f"Payment of ${amount:.2f} initiated - check your browser to complete" + elif result.action == "decline": + return "Payment declined by user" + return "Payment cancelled" + + +@mcp.tool() +async def connect_service(service_name: str, ctx: Context[ServerSession, None]) -> str: + """Connect to a third-party service requiring OAuth authorization. + + This demonstrates the "throw error" pattern using UrlElicitationRequiredError. + Use this pattern when the tool cannot proceed without user authorization. + """ + elicitation_id = str(uuid.uuid4()) + + # Raise UrlElicitationRequiredError to signal that the client must complete + # a URL elicitation before this request can be processed. + # The MCP framework will convert this to a -32042 error response. + raise UrlElicitationRequiredError( + [ + ElicitRequestURLParams( + mode="url", + message=f"Authorization required to connect to {service_name}", + url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}", + elicitationId=elicitation_id, + ) + ] + ) +``` + +_Full example: [examples/snippets/servers/elicitation.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/elicitation.py)_ + + +Elicitation schemas support default values for all field types. Default values are automatically included in the JSON schema sent to clients, allowing them to pre-populate forms. + +The `elicit()` method returns an `ElicitationResult` with: + +- `action`: "accept", "decline", or "cancel" +- `data`: The validated response (only when accepted) +- `validation_error`: Any validation error message + +### Sampling + +Tools can interact with LLMs through sampling (generating text): + + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +from mcp.types import SamplingMessage, TextContent + +mcp = FastMCP(name="Sampling Example") + + +@mcp.tool() +async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str: + """Generate a poem using LLM sampling.""" + prompt = f"Write a short poem about {topic}" + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt), + ) + ], + max_tokens=100, + ) + + # Since we're not passing tools param, result.content is single content + if result.content.type == "text": + return result.content.text + return str(result.content) +``` + +_Full example: [examples/snippets/servers/sampling.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/sampling.py)_ + + +### Logging and Notifications + +Tools can send logs and notifications through the context: + + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP(name="Notifications Example") + + +@mcp.tool() +async def process_data(data: str, ctx: Context[ServerSession, None]) -> str: + """Process data with logging.""" + # Different log levels + await ctx.debug(f"Debug: Processing '{data}'") + await ctx.info("Info: Starting processing") + await ctx.warning("Warning: This is experimental") + await ctx.error("Error: (This is just a demo)") + + # Notify about resource changes + await ctx.session.send_resource_list_changed() + + return f"Processed: {data}" +``` + +_Full example: [examples/snippets/servers/notifications.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/notifications.py)_ + + +### Authentication + +Authentication can be used by servers that want to expose tools accessing protected resources. + +`mcp.server.auth` implements OAuth 2.1 resource server functionality, where MCP servers act as Resource Servers (RS) that validate tokens issued by separate Authorization Servers (AS). This follows the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) and implements RFC 9728 (Protected Resource Metadata) for AS discovery. + +MCP servers can use authentication by providing an implementation of the `TokenVerifier` protocol: + + +```python +""" +Run from the repository root: + uv run examples/snippets/servers/oauth_server.py +""" + +from pydantic import AnyHttpUrl + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp import FastMCP + + +class SimpleTokenVerifier(TokenVerifier): + """Simple token verifier for demonstration.""" + + async def verify_token(self, token: str) -> AccessToken | None: + pass # This is where you would implement actual token validation + + +# Create FastMCP instance as a Resource Server +mcp = FastMCP( + "Weather Service", + json_response=True, + # Token verifier for authentication + token_verifier=SimpleTokenVerifier(), + # Auth settings for RFC 9728 Protected Resource Metadata + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL + resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL + required_scopes=["user"], + ), +) + + +@mcp.tool() +async def get_weather(city: str = "London") -> dict[str, str]: + """Get weather data for a city""" + return { + "city": city, + "temperature": "22", + "condition": "Partly cloudy", + "humidity": "65%", + } + + +if __name__ == "__main__": + mcp.run(transport="streamable-http") +``` + +_Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/oauth_server.py)_ + + +For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/servers/simple-auth/`](examples/servers/simple-auth/). + +**Architecture:** + +- **Authorization Server (AS)**: Handles OAuth flows, user authentication, and token issuance +- **Resource Server (RS)**: Your MCP server that validates tokens and serves protected resources +- **Client**: Discovers AS through RFC 9728, obtains tokens, and uses them with the MCP server + +See [TokenVerifier](src/mcp/server/auth/provider.py) for more details on implementing token validation. + +### FastMCP Properties + +The FastMCP server instance accessible via `ctx.fastmcp` provides access to server configuration and metadata: + +- `ctx.fastmcp.name` - The server's name as defined during initialization +- `ctx.fastmcp.instructions` - Server instructions/description provided to clients +- `ctx.fastmcp.website_url` - Optional website URL for the server +- `ctx.fastmcp.icons` - Optional list of icons for UI display +- `ctx.fastmcp.settings` - Complete server configuration object containing: + - `debug` - Debug mode flag + - `log_level` - Current logging level + - `host` and `port` - Server network configuration + - `mount_path`, `sse_path`, `streamable_http_path` - Transport paths + - `stateless_http` - Whether the server operates in stateless mode + - And other configuration options + +```python +@mcp.tool() +def server_info(ctx: Context) -> dict: + """Get information about the current server.""" + return { + "name": ctx.fastmcp.name, + "instructions": ctx.fastmcp.instructions, + "debug_mode": ctx.fastmcp.settings.debug, + "log_level": ctx.fastmcp.settings.log_level, + "host": ctx.fastmcp.settings.host, + "port": ctx.fastmcp.settings.port, + } +``` + +### Session Properties and Methods + +The session object accessible via `ctx.session` provides advanced control over client communication: + +- `ctx.session.client_params` - Client initialization parameters and declared capabilities +- `await ctx.session.send_log_message(level, data, logger)` - Send log messages with full control +- `await ctx.session.create_message(messages, max_tokens)` - Request LLM sampling/completion +- `await ctx.session.send_progress_notification(token, progress, total, message)` - Direct progress updates +- `await ctx.session.send_resource_updated(uri)` - Notify clients that a specific resource changed +- `await ctx.session.send_resource_list_changed()` - Notify clients that the resource list changed +- `await ctx.session.send_tool_list_changed()` - Notify clients that the tool list changed +- `await ctx.session.send_prompt_list_changed()` - Notify clients that the prompt list changed + +```python +@mcp.tool() +async def notify_data_update(resource_uri: str, ctx: Context) -> str: + """Update data and notify clients of the change.""" + # Perform data update logic here + + # Notify clients that this specific resource changed + await ctx.session.send_resource_updated(AnyUrl(resource_uri)) + + # If this affects the overall resource list, notify about that too + await ctx.session.send_resource_list_changed() + + return f"Updated {resource_uri} and notified clients" +``` + +### Request Context Properties + +The request context accessible via `ctx.request_context` contains request-specific information and resources: + +- `ctx.request_context.lifespan_context` - Access to resources initialized during server startup + - Database connections, configuration objects, shared services + - Type-safe access to resources defined in your server's lifespan function +- `ctx.request_context.meta` - Request metadata from the client including: + - `progressToken` - Token for progress notifications + - Other client-provided metadata +- `ctx.request_context.request` - The original MCP request object for advanced processing +- `ctx.request_context.request_id` - Unique identifier for this request + +```python +# Example with typed lifespan context +@dataclass +class AppContext: + db: Database + config: AppConfig + +@mcp.tool() +def query_with_config(query: str, ctx: Context) -> str: + """Execute a query using shared database and configuration.""" + # Access typed lifespan context + app_ctx: AppContext = ctx.request_context.lifespan_context + + # Use shared resources + connection = app_ctx.db + settings = app_ctx.config + + # Execute query with configuration + result = connection.execute(query, timeout=settings.query_timeout) + return str(result) +``` + +_Full lifespan example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ + +## Running Your Server + +### Development Mode + +The fastest way to test and debug your server is with the MCP Inspector: + +```bash +uv run mcp dev server.py + +# Add dependencies +uv run mcp dev server.py --with pandas --with numpy + +# Mount local code +uv run mcp dev server.py --with-editable . +``` + +### Claude Desktop Integration + +Once your server is ready, install it in Claude Desktop: + +```bash +uv run mcp install server.py + +# Custom name +uv run mcp install server.py --name "My Analytics Server" + +# Environment variables +uv run mcp install server.py -v API_KEY=abc123 -v DB_URL=postgres://... +uv run mcp install server.py -f .env +``` + +### Direct Execution + +For advanced scenarios like custom deployments: + + +```python +"""Example showing direct execution of an MCP server. + +This is the simplest way to run an MCP server directly. +cd to the `examples/snippets` directory and run: + uv run direct-execution-server + or + python servers/direct_execution.py +""" + +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("My App") + + +@mcp.tool() +def hello(name: str = "World") -> str: + """Say hello to someone.""" + return f"Hello, {name}!" + + +def main(): + """Entry point for the direct execution server.""" + mcp.run() + + +if __name__ == "__main__": + main() +``` + +_Full example: [examples/snippets/servers/direct_execution.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_execution.py)_ + + +Run it with: + +```bash +python servers/direct_execution.py +# or +uv run mcp run servers/direct_execution.py +``` + +Note that `uv run mcp run` or `uv run mcp dev` only supports server using FastMCP and not the low-level server variant. + +### Streamable HTTP Transport + +> **Note**: Streamable HTTP transport is the recommended transport for production deployments. Use `stateless_http=True` and `json_response=True` for optimal scalability. + + +```python +""" +Run from the repository root: + uv run examples/snippets/servers/streamable_config.py +""" + +from mcp.server.fastmcp import FastMCP + +# Stateless server with JSON responses (recommended) +mcp = FastMCP("StatelessServer", stateless_http=True, json_response=True) + +# Other configuration options: +# Stateless server with SSE streaming responses +# mcp = FastMCP("StatelessServer", stateless_http=True) + +# Stateful server with session persistence +# mcp = FastMCP("StatefulServer") + + +# Add a simple tool to demonstrate the server +@mcp.tool() +def greet(name: str = "World") -> str: + """Greet someone by name.""" + return f"Hello, {name}!" + + +# Run server with streamable_http transport +if __name__ == "__main__": + mcp.run(transport="streamable-http") +``` + +_Full example: [examples/snippets/servers/streamable_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_config.py)_ + + +You can mount multiple FastMCP servers in a Starlette application: + + +```python +""" +Run from the repository root: + uvicorn examples.snippets.servers.streamable_starlette_mount:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.fastmcp import FastMCP + +# Create the Echo server +echo_mcp = FastMCP(name="EchoServer", stateless_http=True, json_response=True) + + +@echo_mcp.tool() +def echo(message: str) -> str: + """A simple echo tool""" + return f"Echo: {message}" + + +# Create the Math server +math_mcp = FastMCP(name="MathServer", stateless_http=True, json_response=True) + + +@math_mcp.tool() +def add_two(n: int) -> int: + """Tool to add two to the input""" + return n + 2 + + +# Create a combined lifespan to manage both session managers +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(echo_mcp.session_manager.run()) + await stack.enter_async_context(math_mcp.session_manager.run()) + yield + + +# Create the Starlette app and mount the MCP servers +app = Starlette( + routes=[ + Mount("/echo", echo_mcp.streamable_http_app()), + Mount("/math", math_mcp.streamable_http_app()), + ], + lifespan=lifespan, +) + +# Note: Clients connect to http://localhost:8000/echo/mcp and http://localhost:8000/math/mcp +# To mount at the root of each path (e.g., /echo instead of /echo/mcp): +# echo_mcp.settings.streamable_http_path = "/" +# math_mcp.settings.streamable_http_path = "/" +``` + +_Full example: [examples/snippets/servers/streamable_starlette_mount.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_starlette_mount.py)_ + + +For low level server with Streamable HTTP implementations, see: + +- Stateful server: [`examples/servers/simple-streamablehttp/`](examples/servers/simple-streamablehttp/) +- Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](examples/servers/simple-streamablehttp-stateless/) + +The streamable HTTP transport supports: + +- Stateful and stateless operation modes +- Resumability with event stores +- JSON or SSE response formats +- Better scalability for multi-node deployments + +#### CORS Configuration for Browser-Based Clients + +If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it: + +```python +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware + +# Create your Starlette app first +starlette_app = Starlette(routes=[...]) + +# Then wrap it with CORS middleware +starlette_app = CORSMiddleware( + starlette_app, + allow_origins=["*"], # Configure appropriately for production + allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods + expose_headers=["Mcp-Session-Id"], +) +``` + +This configuration is necessary because: + +- The MCP streamable HTTP transport uses the `Mcp-Session-Id` header for session management +- Browsers restrict access to response headers unless explicitly exposed via CORS +- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses + +### Mounting to an Existing ASGI Server + +By default, SSE servers are mounted at `/sse` and Streamable HTTP servers are mounted at `/mcp`. You can customize these paths using the methods described below. + +For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). + +#### StreamableHTTP servers + +You can mount the StreamableHTTP server to an existing ASGI server using the `streamable_http_app` method. This allows you to integrate the StreamableHTTP server with other ASGI applications. + +##### Basic mounting + + +```python +""" +Basic example showing how to mount StreamableHTTP server in Starlette. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_basic_mounting:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.fastmcp import FastMCP + +# Create MCP server +mcp = FastMCP("My App", json_response=True) + + +@mcp.tool() +def hello() -> str: + """A simple hello tool""" + return "Hello from MCP!" + + +# Create a lifespan context manager to run the session manager +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with mcp.session_manager.run(): + yield + + +# Mount the StreamableHTTP server to the existing ASGI server +app = Starlette( + routes=[ + Mount("/", app=mcp.streamable_http_app()), + ], + lifespan=lifespan, +) +``` + +_Full example: [examples/snippets/servers/streamable_http_basic_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_basic_mounting.py)_ + + +##### Host-based routing + + +```python +""" +Example showing how to mount StreamableHTTP server using Host-based routing. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_host_mounting:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Host + +from mcp.server.fastmcp import FastMCP + +# Create MCP server +mcp = FastMCP("MCP Host App", json_response=True) + + +@mcp.tool() +def domain_info() -> str: + """Get domain-specific information""" + return "This is served from mcp.acme.corp" + + +# Create a lifespan context manager to run the session manager +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with mcp.session_manager.run(): + yield + + +# Mount using Host-based routing +app = Starlette( + routes=[ + Host("mcp.acme.corp", app=mcp.streamable_http_app()), + ], + lifespan=lifespan, +) +``` + +_Full example: [examples/snippets/servers/streamable_http_host_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_host_mounting.py)_ + + +##### Multiple servers with path configuration + + +```python +""" +Example showing how to mount multiple StreamableHTTP servers with path configuration. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_multiple_servers:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.fastmcp import FastMCP + +# Create multiple MCP servers +api_mcp = FastMCP("API Server", json_response=True) +chat_mcp = FastMCP("Chat Server", json_response=True) + + +@api_mcp.tool() +def api_status() -> str: + """Get API status""" + return "API is running" + + +@chat_mcp.tool() +def send_message(message: str) -> str: + """Send a chat message""" + return f"Message sent: {message}" + + +# Configure servers to mount at the root of each path +# This means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp +api_mcp.settings.streamable_http_path = "/" +chat_mcp.settings.streamable_http_path = "/" + + +# Create a combined lifespan to manage both session managers +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(api_mcp.session_manager.run()) + await stack.enter_async_context(chat_mcp.session_manager.run()) + yield + + +# Mount the servers +app = Starlette( + routes=[ + Mount("/api", app=api_mcp.streamable_http_app()), + Mount("/chat", app=chat_mcp.streamable_http_app()), + ], + lifespan=lifespan, +) +``` + +_Full example: [examples/snippets/servers/streamable_http_multiple_servers.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_multiple_servers.py)_ + + +##### Path configuration at initialization + + +```python +""" +Example showing path configuration during FastMCP initialization. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_path_config:app --reload +""" + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.fastmcp import FastMCP + +# Configure streamable_http_path during initialization +# This server will mount at the root of wherever it's mounted +mcp_at_root = FastMCP( + "My Server", + json_response=True, + streamable_http_path="/", +) + + +@mcp_at_root.tool() +def process_data(data: str) -> str: + """Process some data""" + return f"Processed: {data}" + + +# Mount at /process - endpoints will be at /process instead of /process/mcp +app = Starlette( + routes=[ + Mount("/process", app=mcp_at_root.streamable_http_app()), + ] +) +``` + +_Full example: [examples/snippets/servers/streamable_http_path_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_path_config.py)_ + + +#### SSE servers + +> **Note**: SSE transport is being superseded by [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). + +You can mount the SSE server to an existing ASGI server using the `sse_app` method. This allows you to integrate the SSE server with other ASGI applications. + +```python +from starlette.applications import Starlette +from starlette.routing import Mount, Host +from mcp.server.fastmcp import FastMCP + + +mcp = FastMCP("My App") + +# Mount the SSE server to the existing ASGI server +app = Starlette( + routes=[ + Mount('/', app=mcp.sse_app()), + ] +) + +# or dynamically mount as host +app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) +``` + +When mounting multiple MCP servers under different paths, you can configure the mount path in several ways: + +```python +from starlette.applications import Starlette +from starlette.routing import Mount +from mcp.server.fastmcp import FastMCP + +# Create multiple MCP servers +github_mcp = FastMCP("GitHub API") +browser_mcp = FastMCP("Browser") +curl_mcp = FastMCP("Curl") +search_mcp = FastMCP("Search") + +# Method 1: Configure mount paths via settings (recommended for persistent configuration) +github_mcp.settings.mount_path = "/github" +browser_mcp.settings.mount_path = "/browser" + +# Method 2: Pass mount path directly to sse_app (preferred for ad-hoc mounting) +# This approach doesn't modify the server's settings permanently + +# Create Starlette app with multiple mounted servers +app = Starlette( + routes=[ + # Using settings-based configuration + Mount("/github", app=github_mcp.sse_app()), + Mount("/browser", app=browser_mcp.sse_app()), + # Using direct mount path parameter + Mount("/curl", app=curl_mcp.sse_app("/curl")), + Mount("/search", app=search_mcp.sse_app("/search")), + ] +) + +# Method 3: For direct execution, you can also pass the mount path to run() +if __name__ == "__main__": + search_mcp.run(transport="sse", mount_path="/search") +``` + +For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). + +## Advanced Usage + +### Low-Level Server + +For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server, including lifecycle management through the lifespan API: + + +```python +""" +Run from the repository root: + uv run examples/snippets/servers/lowlevel/lifespan.py +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + + +# Mock database class for example +class Database: + """Mock database class for example.""" + + @classmethod + async def connect(cls) -> "Database": + """Connect to database.""" + print("Database connected") + return cls() + + async def disconnect(self) -> None: + """Disconnect from database.""" + print("Database disconnected") + + async def query(self, query_str: str) -> list[dict[str, str]]: + """Execute a query.""" + # Simulate database query + return [{"id": "1", "name": "Example", "query": query_str}] + + +@asynccontextmanager +async def server_lifespan(_server: Server) -> AsyncIterator[dict[str, Any]]: + """Manage server startup and shutdown lifecycle.""" + # Initialize resources on startup + db = await Database.connect() + try: + yield {"db": db} + finally: + # Clean up on shutdown + await db.disconnect() + + +# Pass lifespan to server +server = Server("example-server", lifespan=server_lifespan) + + +@server.list_tools() +async def handle_list_tools() -> list[types.Tool]: + """List available tools.""" + return [ + types.Tool( + name="query_db", + description="Query the database", + inputSchema={ + "type": "object", + "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def query_db(name: str, arguments: dict[str, Any]) -> list[types.TextContent]: + """Handle database query tool call.""" + if name != "query_db": + raise ValueError(f"Unknown tool: {name}") + + # Access lifespan context + ctx = server.request_context + db = ctx.lifespan_context["db"] + + # Execute query + results = await db.query(arguments["query"]) + + return [types.TextContent(type="text", text=f"Query results: {results}")] + + +async def run(): + """Run the server with lifespan management.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="example-server", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + import asyncio + + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/lifespan.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/lifespan.py)_ + + +The lifespan API provides: + +- A way to initialize resources when the server starts and clean them up when it stops +- Access to initialized resources through the request context in handlers +- Type-safe context passing between lifespan and request handlers + + +```python +""" +Run from the repository root: +uv run examples/snippets/servers/lowlevel/basic.py +""" + +import asyncio + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +# Create a server instance +server = Server("example-server") + + +@server.list_prompts() +async def handle_list_prompts() -> list[types.Prompt]: + """List available prompts.""" + return [ + types.Prompt( + name="example-prompt", + description="An example prompt template", + arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], + ) + ] + + +@server.get_prompt() +async def handle_get_prompt(name: str, arguments: dict[str, str] | None) -> types.GetPromptResult: + """Get a specific prompt by name.""" + if name != "example-prompt": + raise ValueError(f"Unknown prompt: {name}") + + arg1_value = (arguments or {}).get("arg1", "default") + + return types.GetPromptResult( + description="Example prompt", + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text=f"Example prompt text with argument: {arg1_value}"), + ) + ], + ) + + +async def run(): + """Run the basic low-level server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/basic.py)_ + + +Caution: The `uv run mcp run` and `uv run mcp dev` tool doesn't support low-level server. + +#### Structured Output Support + +The low-level server supports structured output for tools, allowing you to return both human-readable content and machine-readable structured data. Tools can define an `outputSchema` to validate their structured output: + + +```python +""" +Run from the repository root: + uv run examples/snippets/servers/lowlevel/structured_output.py +""" + +import asyncio +from typing import Any + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +server = Server("example-server") + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """List available tools with structured output schemas.""" + return [ + types.Tool( + name="get_weather", + description="Get current weather for a city", + inputSchema={ + "type": "object", + "properties": {"city": {"type": "string", "description": "City name"}}, + "required": ["city"], + }, + outputSchema={ + "type": "object", + "properties": { + "temperature": {"type": "number", "description": "Temperature in Celsius"}, + "condition": {"type": "string", "description": "Weather condition"}, + "humidity": {"type": "number", "description": "Humidity percentage"}, + "city": {"type": "string", "description": "City name"}, + }, + "required": ["temperature", "condition", "humidity", "city"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + """Handle tool calls with structured output.""" + if name == "get_weather": + city = arguments["city"] + + # Simulated weather data - in production, call a weather API + weather_data = { + "temperature": 22.5, + "condition": "partly cloudy", + "humidity": 65, + "city": city, # Include the requested city + } + + # low-level server will validate structured output against the tool's + # output schema, and additionally serialize it into a TextContent block + # for backwards compatibility with pre-2025-06-18 clients. + return weather_data + else: + raise ValueError(f"Unknown tool: {name}") + + +async def run(): + """Run the structured output server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="structured-output-example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/structured_output.py)_ + + +Tools can return data in four ways: + +1. **Content only**: Return a list of content blocks (default behavior before spec revision 2025-06-18) +2. **Structured data only**: Return a dictionary that will be serialized to JSON (Introduced in spec revision 2025-06-18) +3. **Both**: Return a tuple of (content, structured_data) preferred option to use for backwards compatibility +4. **Direct CallToolResult**: Return `CallToolResult` directly for full control (including `_meta` field) + +When an `outputSchema` is defined, the server automatically validates the structured output against the schema. This ensures type safety and helps catch errors early. + +##### Returning CallToolResult Directly + +For full control over the response including the `_meta` field (for passing data to client applications without exposing it to the model), return `CallToolResult` directly: + + +```python +""" +Run from the repository root: + uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py +""" + +import asyncio +from typing import Any + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +server = Server("example-server") + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """List available tools.""" + return [ + types.Tool( + name="advanced_tool", + description="Tool with full control including _meta field", + inputSchema={ + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + }, + ) + ] + + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult: + """Handle tool calls by returning CallToolResult directly.""" + if name == "advanced_tool": + message = str(arguments.get("message", "")) + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Processed: {message}")], + structuredContent={"result": "success", "message": message}, + _meta={"hidden": "data for client applications only"}, + ) + + raise ValueError(f"Unknown tool: {name}") + + +async def run(): + """Run the server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/direct_call_tool_result.py)_ + + +**Note:** When returning `CallToolResult`, you bypass the automatic content/structured conversion. You must construct the complete response yourself. + +### Pagination (Advanced) + +For servers that need to handle large datasets, the low-level server provides paginated versions of list operations. This is an optional optimization - most servers won't need pagination unless they're dealing with hundreds or thousands of items. + +#### Server-side Implementation + + +```python +""" +Example of implementing pagination with MCP server decorators. +""" + +from pydantic import AnyUrl + +import mcp.types as types +from mcp.server.lowlevel import Server + +# Initialize the server +server = Server("paginated-server") + +# Sample data to paginate +ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items + + +@server.list_resources() +async def list_resources_paginated(request: types.ListResourcesRequest) -> types.ListResourcesResult: + """List resources with pagination support.""" + page_size = 10 + + # Extract cursor from request params + cursor = request.params.cursor if request.params is not None else None + + # Parse cursor to get offset + start = 0 if cursor is None else int(cursor) + end = start + page_size + + # Get page of resources + page_items = [ + types.Resource(uri=AnyUrl(f"resource://items/{item}"), name=item, description=f"Description for {item}") + for item in ITEMS[start:end] + ] + + # Determine next cursor + next_cursor = str(end) if end < len(ITEMS) else None + + return types.ListResourcesResult(resources=page_items, nextCursor=next_cursor) +``` + +_Full example: [examples/snippets/servers/pagination_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/pagination_example.py)_ + + +#### Client-side Consumption + + +```python +""" +Example of consuming paginated MCP endpoints from a client. +""" + +import asyncio + +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.types import PaginatedRequestParams, Resource + + +async def list_all_resources() -> None: + """Fetch all resources using pagination.""" + async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as ( + read, + write, + ): + async with ClientSession(read, write) as session: + await session.initialize() + + all_resources: list[Resource] = [] + cursor = None + + while True: + # Fetch a page of resources + result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor)) + all_resources.extend(result.resources) + + print(f"Fetched {len(result.resources)} resources") + + # Check if there are more pages + if result.nextCursor: + cursor = result.nextCursor + else: + break + + print(f"Total resources: {len(all_resources)}") + + +if __name__ == "__main__": + asyncio.run(list_all_resources()) +``` + +_Full example: [examples/snippets/clients/pagination_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/pagination_client.py)_ + + +#### Key Points + +- **Cursors are opaque strings** - the server defines the format (numeric offsets, timestamps, etc.) +- **Return `nextCursor=None`** when there are no more pages +- **Backward compatible** - clients that don't support pagination will still work (they'll just get the first page) +- **Flexible page sizes** - Each endpoint can define its own page size based on data characteristics + +See the [simple-pagination example](examples/servers/simple-pagination) for a complete implementation. + +### Writing MCP Clients + +The SDK provides a high-level client interface for connecting to MCP servers using various [transports](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports): + + +```python +""" +cd to the `examples/snippets/clients` directory and run: + uv run client +""" + +import asyncio +import os + +from pydantic import AnyUrl + +from mcp import ClientSession, StdioServerParameters, types +from mcp.client.stdio import stdio_client +from mcp.shared.context import RequestContext + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "fastmcp_quickstart", "stdio"], # We're already in snippets dir + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +# Optional: create a sampling callback +async def handle_sampling_message( + context: RequestContext[ClientSession, None], params: types.CreateMessageRequestParams +) -> types.CreateMessageResult: + print(f"Sampling request: {params.messages}") + return types.CreateMessageResult( + role="assistant", content=types.TextContent( type="text", text="Hello, world! from model", @@ -588,37 +2181,346 @@ async def handle_sampling_message( async def run(): async with stdio_client(server_params) as (read, write): - async with ClientSession( - read, write, sampling_callback=handle_sampling_message - ) as session: + async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: # Initialize the connection await session.initialize() # List available prompts prompts = await session.list_prompts() + print(f"Available prompts: {[p.name for p in prompts.prompts]}") - # Get a prompt - prompt = await session.get_prompt( - "example-prompt", arguments={"arg1": "value"} - ) + # Get a prompt (greet_user prompt from fastmcp_quickstart) + if prompts.prompts: + prompt = await session.get_prompt("greet_user", arguments={"name": "Alice", "style": "friendly"}) + print(f"Prompt result: {prompt.messages[0].content}") # List available resources resources = await session.list_resources() + print(f"Available resources: {[r.uri for r in resources.resources]}") # List available tools tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}") + + # Read a resource (greeting resource from fastmcp_quickstart) + resource_content = await session.read_resource(AnyUrl("greeting://World")) + content_block = resource_content.contents[0] + if isinstance(content_block, types.TextContent): + print(f"Resource content: {content_block.text}") + + # Call a tool (add tool from fastmcp_quickstart) + result = await session.call_tool("add", arguments={"a": 5, "b": 3}) + result_unstructured = result.content[0] + if isinstance(result_unstructured, types.TextContent): + print(f"Tool result: {result_unstructured.text}") + result_structured = result.structuredContent + print(f"Structured tool result: {result_structured}") - # Read a resource - content, mime_type = await session.read_resource("file://some/path") - # Call a tool - result = await session.call_tool("tool-name", arguments={"arg1": "value"}) +def main(): + """Entry point for the client script.""" + asyncio.run(run()) if __name__ == "__main__": - import asyncio + main() +``` + +_Full example: [examples/snippets/clients/stdio_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/stdio_client.py)_ + + +Clients can also connect using [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http): + + +```python +""" +Run from the repository root: + uv run examples/snippets/clients/streamable_basic.py +""" + +import asyncio + +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client + + +async def main(): + # Connect to a streamable HTTP server + async with streamable_http_client("http://localhost:8000/mcp") as ( + read_stream, + write_stream, + _, + ): + # Create a session using the client streams + async with ClientSession(read_stream, write_stream) as session: + # Initialize the connection + await session.initialize() + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +_Full example: [examples/snippets/clients/streamable_basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/streamable_basic.py)_ + + +### Client Display Utilities + +When building MCP clients, the SDK provides utilities to help display human-readable names for tools, resources, and prompts: + + +```python +""" +cd to the `examples/snippets` directory and run: + uv run display-utilities-client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.shared.metadata_utils import get_display_name + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "fastmcp_quickstart", "stdio"], + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +async def display_tools(session: ClientSession): + """Display available tools with human-readable names""" + tools_response = await session.list_tools() + + for tool in tools_response.tools: + # get_display_name() returns the title if available, otherwise the name + display_name = get_display_name(tool) + print(f"Tool: {display_name}") + if tool.description: + print(f" {tool.description}") + + +async def display_resources(session: ClientSession): + """Display available resources with human-readable names""" + resources_response = await session.list_resources() + + for resource in resources_response.resources: + display_name = get_display_name(resource) + print(f"Resource: {display_name} ({resource.uri})") + + templates_response = await session.list_resource_templates() + for template in templates_response.resourceTemplates: + display_name = get_display_name(template) + print(f"Resource Template: {display_name}") + + +async def run(): + """Run the display utilities example.""" + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + print("=== Available Tools ===") + await display_tools(session) + + print("\n=== Available Resources ===") + await display_resources(session) + + +def main(): + """Entry point for the display utilities client.""" asyncio.run(run()) + + +if __name__ == "__main__": + main() +``` + +_Full example: [examples/snippets/clients/display_utilities.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/display_utilities.py)_ + + +The `get_display_name()` function implements the proper precedence rules for displaying names: + +- For tools: `title` > `annotations.title` > `name` +- For other objects: `title` > `name` + +This ensures your client UI shows the most user-friendly names that servers provide. + +### OAuth Authentication for Clients + +The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) for connecting to protected MCP servers: + + +```python +""" +Before running, specify running MCP RS server URL. +To spin up RS server locally, see + examples/servers/simple-auth/README.md + +cd to the `examples/snippets` directory and run: + uv run oauth-client +""" + +import asyncio +from urllib.parse import parse_qs, urlparse + +import httpx +from pydantic import AnyUrl + +from mcp import ClientSession +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class InMemoryTokenStorage(TokenStorage): + """Demo In-memory token storage implementation.""" + + def __init__(self): + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + """Get stored tokens.""" + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + """Store tokens.""" + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + """Get stored client information.""" + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + """Store client information.""" + self.client_info = client_info + + +async def handle_redirect(auth_url: str) -> None: + print(f"Visit: {auth_url}") + + +async def handle_callback() -> tuple[str, str | None]: + callback_url = input("Paste callback URL: ") + params = parse_qs(urlparse(callback_url).query) + return params["code"][0], params.get("state", [None])[0] + + +async def main(): + """Run the OAuth client example.""" + oauth_auth = OAuthClientProvider( + server_url="http://localhost:8001", + client_metadata=OAuthClientMetadata( + client_name="Example MCP Client", + redirect_uris=[AnyUrl("http://localhost:3000/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scope="user", + ), + storage=InMemoryTokenStorage(), + redirect_handler=handle_redirect, + callback_handler=handle_callback, + ) + + async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: + async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + resources = await session.list_resources() + print(f"Available resources: {[r.uri for r in resources.resources]}") + + +def run(): + asyncio.run(main()) + + +if __name__ == "__main__": + run() +``` + +_Full example: [examples/snippets/clients/oauth_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/oauth_client.py)_ + + +For a complete working example, see [`examples/clients/simple-auth-client/`](examples/clients/simple-auth-client/). + +### Parsing Tool Results + +When calling tools through MCP, the `CallToolResult` object contains the tool's response in a structured format. Understanding how to parse this result is essential for properly handling tool outputs. + +```python +"""examples/snippets/clients/parsing_tool_results.py""" + +import asyncio + +from mcp import ClientSession, StdioServerParameters, types +from mcp.client.stdio import stdio_client + + +async def parse_tool_results(): + """Demonstrates how to parse different types of content in CallToolResult.""" + server_params = StdioServerParameters( + command="python", args=["path/to/mcp_server.py"] + ) + + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # Example 1: Parsing text content + result = await session.call_tool("get_data", {"format": "text"}) + for content in result.content: + if isinstance(content, types.TextContent): + print(f"Text: {content.text}") + + # Example 2: Parsing structured content from JSON tools + result = await session.call_tool("get_user", {"id": "123"}) + if hasattr(result, "structuredContent") and result.structuredContent: + # Access structured data directly + user_data = result.structuredContent + print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}") + + # Example 3: Parsing embedded resources + result = await session.call_tool("read_config", {}) + for content in result.content: + if isinstance(content, types.EmbeddedResource): + resource = content.resource + if isinstance(resource, types.TextResourceContents): + print(f"Config from {resource.uri}: {resource.text}") + elif isinstance(resource, types.BlobResourceContents): + print(f"Binary data from {resource.uri}") + + # Example 4: Parsing image content + result = await session.call_tool("generate_chart", {"data": [1, 2, 3]}) + for content in result.content: + if isinstance(content, types.ImageContent): + print(f"Image ({content.mimeType}): {len(content.data)} bytes") + + # Example 5: Handling errors + result = await session.call_tool("failing_tool", {}) + if result.isError: + print("Tool execution failed!") + for content in result.content: + if isinstance(content, types.TextContent): + print(f"Error: {content.text}") + + +async def main(): + await parse_tool_results() + + +if __name__ == "__main__": + asyncio.run(main()) ``` ### MCP Primitives @@ -635,18 +2537,20 @@ The MCP protocol defines three core primitives that servers can implement: MCP servers declare capabilities during initialization: -| Capability | Feature Flag | Description | -|-------------|------------------------------|------------------------------------| -| `prompts` | `listChanged` | Prompt template management | -| `resources` | `subscribe`
`listChanged`| Resource exposure and updates | -| `tools` | `listChanged` | Tool discovery and execution | -| `logging` | - | Server logging configuration | -| `completion`| - | Argument completion suggestions | +| Capability | Feature Flag | Description | +|--------------|------------------------------|------------------------------------| +| `prompts` | `listChanged` | Prompt template management | +| `resources` | `subscribe`
`listChanged`| Resource exposure and updates | +| `tools` | `listChanged` | Tool discovery and execution | +| `logging` | - | Server logging configuration | +| `completions`| - | Argument completion suggestions | ## Documentation +- [API Reference](https://modelcontextprotocol.github.io/python-sdk/api/) +- [Experimental Features (Tasks)](https://modelcontextprotocol.github.io/python-sdk/experimental/tasks/) - [Model Context Protocol documentation](https://modelcontextprotocol.io) -- [Model Context Protocol specification](https://spec.modelcontextprotocol.io) +- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/latest) - [Officially supported servers](https://github.com/modelcontextprotocol/servers) ## Contributing diff --git a/SECURITY.md b/SECURITY.md index 8c09400cce..6545156105 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,4 +1,5 @@ # Security Policy + Thank you for helping us keep the SDKs and systems they interact with secure. ## Reporting Security Issues diff --git a/docs/authorization.md b/docs/authorization.md new file mode 100644 index 0000000000..4b6208bdfc --- /dev/null +++ b/docs/authorization.md @@ -0,0 +1,5 @@ +# Authorization + +!!! warning "Under Construction" + + This page is currently being written. Check back soon for complete documentation. diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000000..a2d6eb8d3a --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,13 @@ +# Concepts + +!!! warning "Under Construction" + + This page is currently being written. Check back soon for complete documentation. + + diff --git a/docs/experimental/index.md b/docs/experimental/index.md new file mode 100644 index 0000000000..1d496b3f10 --- /dev/null +++ b/docs/experimental/index.md @@ -0,0 +1,43 @@ +# Experimental Features + +!!! warning "Experimental APIs" + + The features in this section are experimental and may change without notice. + They track the evolving MCP specification and are not yet stable. + +This section documents experimental features in the MCP Python SDK. These features +implement draft specifications that are still being refined. + +## Available Experimental Features + +### [Tasks](tasks.md) + +Tasks enable asynchronous execution of MCP operations. Instead of waiting for a +long-running operation to complete, the server returns a task reference immediately. +Clients can then poll for status updates and retrieve results when ready. + +Tasks are useful for: + +- **Long-running computations** that would otherwise block +- **Batch operations** that process many items +- **Interactive workflows** that require user input (elicitation) or LLM assistance (sampling) + +## Using Experimental APIs + +Experimental features are accessed via the `.experimental` property: + +```python +# Server-side +@server.experimental.get_task() +async def handle_get_task(request: GetTaskRequest) -> GetTaskResult: + ... + +# Client-side +result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) +``` + +## Providing Feedback + +Since these features are experimental, feedback is especially valuable. If you encounter +issues or have suggestions, please open an issue on the +[python-sdk repository](https://github.com/modelcontextprotocol/python-sdk/issues). diff --git a/docs/experimental/tasks-client.md b/docs/experimental/tasks-client.md new file mode 100644 index 0000000000..cfd23e4e14 --- /dev/null +++ b/docs/experimental/tasks-client.md @@ -0,0 +1,361 @@ +# Client Task Usage + +!!! warning "Experimental" + + Tasks are an experimental feature. The API may change without notice. + +This guide covers calling task-augmented tools from clients, handling the `input_required` status, and advanced patterns like receiving task requests from servers. + +## Quick Start + +Call a tool as a task and poll for the result: + +```python +from mcp.client.session import ClientSession +from mcp.types import CallToolResult + +async with ClientSession(read, write) as session: + await session.initialize() + + # Call tool as task + result = await session.experimental.call_tool_as_task( + "process_data", + {"input": "hello"}, + ttl=60000, + ) + task_id = result.task.taskId + + # Poll until complete + async for status in session.experimental.poll_task(task_id): + print(f"Status: {status.status} - {status.statusMessage or ''}") + + # Get result + final = await session.experimental.get_task_result(task_id, CallToolResult) + print(f"Result: {final.content[0].text}") +``` + +## Calling Tools as Tasks + +Use `call_tool_as_task()` to invoke a tool with task augmentation: + +```python +result = await session.experimental.call_tool_as_task( + "my_tool", # Tool name + {"arg": "value"}, # Arguments + ttl=60000, # Time-to-live in milliseconds + meta={"key": "val"}, # Optional metadata +) + +task_id = result.task.taskId +print(f"Task: {task_id}, Status: {result.task.status}") +``` + +The response is a `CreateTaskResult` containing: + +- `task.taskId` - Unique identifier for polling +- `task.status` - Initial status (usually `"working"`) +- `task.pollInterval` - Suggested polling interval (milliseconds) +- `task.ttl` - Time-to-live for results +- `task.createdAt` - Creation timestamp + +## Polling with poll_task + +The `poll_task()` async iterator polls until the task reaches a terminal state: + +```python +async for status in session.experimental.poll_task(task_id): + print(f"Status: {status.status}") + if status.statusMessage: + print(f"Progress: {status.statusMessage}") +``` + +It automatically: + +- Respects the server's suggested `pollInterval` +- Stops when status is `completed`, `failed`, or `cancelled` +- Yields each status for progress display + +### Handling input_required + +When a task needs user input (elicitation), it transitions to `input_required`. You must call `get_task_result()` to receive and respond to the elicitation: + +```python +async for status in session.experimental.poll_task(task_id): + print(f"Status: {status.status}") + + if status.status == "input_required": + # This delivers the elicitation and waits for completion + final = await session.experimental.get_task_result(task_id, CallToolResult) + break +``` + +The elicitation callback (set during session creation) handles the actual user interaction. + +## Elicitation Callbacks + +To handle elicitation requests from the server, provide a callback when creating the session: + +```python +from mcp.types import ElicitRequestParams, ElicitResult + +async def handle_elicitation(context, params: ElicitRequestParams) -> ElicitResult: + # Display the message to the user + print(f"Server asks: {params.message}") + + # Collect user input (this is a simplified example) + response = input("Your response (y/n): ") + confirmed = response.lower() == "y" + + return ElicitResult( + action="accept", + content={"confirm": confirmed}, + ) + +async with ClientSession( + read, + write, + elicitation_callback=handle_elicitation, +) as session: + await session.initialize() + # ... call tasks that may require elicitation +``` + +## Sampling Callbacks + +Similarly, handle sampling requests with a callback: + +```python +from mcp.types import CreateMessageRequestParams, CreateMessageResult, TextContent + +async def handle_sampling(context, params: CreateMessageRequestParams) -> CreateMessageResult: + # In a real implementation, call your LLM here + prompt = params.messages[-1].content.text if params.messages else "" + + # Return a mock response + return CreateMessageResult( + role="assistant", + content=TextContent(type="text", text=f"Response to: {prompt}"), + model="my-model", + ) + +async with ClientSession( + read, + write, + sampling_callback=handle_sampling, +) as session: + # ... +``` + +## Retrieving Results + +Once a task completes, retrieve the result: + +```python +if status.status == "completed": + result = await session.experimental.get_task_result(task_id, CallToolResult) + for content in result.content: + if hasattr(content, "text"): + print(content.text) + +elif status.status == "failed": + print(f"Task failed: {status.statusMessage}") + +elif status.status == "cancelled": + print("Task was cancelled") +``` + +The result type matches the original request: + +- `tools/call` → `CallToolResult` +- `sampling/createMessage` → `CreateMessageResult` +- `elicitation/create` → `ElicitResult` + +## Cancellation + +Cancel a running task: + +```python +cancel_result = await session.experimental.cancel_task(task_id) +print(f"Cancelled, status: {cancel_result.status}") +``` + +Note: Cancellation is cooperative—the server must check for and handle cancellation. + +## Listing Tasks + +View all tasks on the server: + +```python +result = await session.experimental.list_tasks() +for task in result.tasks: + print(f"{task.taskId}: {task.status}") + +# Handle pagination +while result.nextCursor: + result = await session.experimental.list_tasks(cursor=result.nextCursor) + for task in result.tasks: + print(f"{task.taskId}: {task.status}") +``` + +## Advanced: Client as Task Receiver + +Servers can send task-augmented requests to clients. This is useful when the server needs the client to perform async work (like complex sampling or user interaction). + +### Declaring Client Capabilities + +Register task handlers to declare what task-augmented requests your client accepts: + +```python +from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers +from mcp.types import ( + CreateTaskResult, GetTaskResult, GetTaskPayloadResult, + TaskMetadata, ElicitRequestParams, +) +from mcp.shared.experimental.tasks import InMemoryTaskStore + +# Client-side task store +client_store = InMemoryTaskStore() + +async def handle_augmented_elicitation(context, params: ElicitRequestParams, task_metadata: TaskMetadata): + """Handle task-augmented elicitation from server.""" + # Create a task for this elicitation + task = await client_store.create_task(task_metadata) + + # Start async work (e.g., show UI, wait for user) + async def complete_elicitation(): + # ... do async work ... + result = ElicitResult(action="accept", content={"confirm": True}) + await client_store.store_result(task.taskId, result) + await client_store.update_task(task.taskId, status="completed") + + context.session._task_group.start_soon(complete_elicitation) + + # Return task reference immediately + return CreateTaskResult(task=task) + +async def handle_get_task(context, params): + """Handle tasks/get from server.""" + task = await client_store.get_task(params.taskId) + return GetTaskResult( + taskId=task.taskId, + status=task.status, + statusMessage=task.statusMessage, + createdAt=task.createdAt, + lastUpdatedAt=task.lastUpdatedAt, + ttl=task.ttl, + pollInterval=100, + ) + +async def handle_get_task_result(context, params): + """Handle tasks/result from server.""" + result = await client_store.get_result(params.taskId) + return GetTaskPayloadResult.model_validate(result.model_dump()) + +task_handlers = ExperimentalTaskHandlers( + augmented_elicitation=handle_augmented_elicitation, + get_task=handle_get_task, + get_task_result=handle_get_task_result, +) + +async with ClientSession( + read, + write, + experimental_task_handlers=task_handlers, +) as session: + # Client now accepts task-augmented elicitation from server + await session.initialize() +``` + +This enables flows where: + +1. Client calls a task-augmented tool +2. Server's tool work calls `task.elicit_as_task()` +3. Client receives task-augmented elicitation +4. Client creates its own task, does async work +5. Server polls client's task +6. Eventually both tasks complete + +## Complete Example + +A client that handles all task scenarios: + +```python +import anyio +from mcp.client.session import ClientSession +from mcp.client.stdio import stdio_client +from mcp.types import CallToolResult, ElicitRequestParams, ElicitResult + + +async def elicitation_callback(context, params: ElicitRequestParams) -> ElicitResult: + print(f"\n[Elicitation] {params.message}") + response = input("Confirm? (y/n): ") + return ElicitResult(action="accept", content={"confirm": response.lower() == "y"}) + + +async def main(): + async with stdio_client(command="python", args=["server.py"]) as (read, write): + async with ClientSession( + read, + write, + elicitation_callback=elicitation_callback, + ) as session: + await session.initialize() + + # List available tools + tools = await session.list_tools() + print("Tools:", [t.name for t in tools.tools]) + + # Call a task-augmented tool + print("\nCalling task tool...") + result = await session.experimental.call_tool_as_task( + "confirm_action", + {"action": "delete files"}, + ) + task_id = result.task.taskId + print(f"Task created: {task_id}") + + # Poll and handle input_required + async for status in session.experimental.poll_task(task_id): + print(f"Status: {status.status}") + + if status.status == "input_required": + final = await session.experimental.get_task_result(task_id, CallToolResult) + print(f"Result: {final.content[0].text}") + break + + if status.status == "completed": + final = await session.experimental.get_task_result(task_id, CallToolResult) + print(f"Result: {final.content[0].text}") + + +if __name__ == "__main__": + anyio.run(main) +``` + +## Error Handling + +Handle task errors gracefully: + +```python +from mcp.shared.exceptions import McpError + +try: + result = await session.experimental.call_tool_as_task("my_tool", args) + task_id = result.task.taskId + + async for status in session.experimental.poll_task(task_id): + if status.status == "failed": + raise RuntimeError(f"Task failed: {status.statusMessage}") + + final = await session.experimental.get_task_result(task_id, CallToolResult) + +except McpError as e: + print(f"MCP error: {e.error.message}") +except Exception as e: + print(f"Error: {e}") +``` + +## Next Steps + +- [Server Implementation](tasks-server.md) - Build task-supporting servers +- [Tasks Overview](tasks.md) - Review lifecycle and concepts diff --git a/docs/experimental/tasks-server.md b/docs/experimental/tasks-server.md new file mode 100644 index 0000000000..761dc5de5c --- /dev/null +++ b/docs/experimental/tasks-server.md @@ -0,0 +1,597 @@ +# Server Task Implementation + +!!! warning "Experimental" + + Tasks are an experimental feature. The API may change without notice. + +This guide covers implementing task support in MCP servers, from basic setup to advanced patterns like elicitation and sampling within tasks. + +## Quick Start + +The simplest way to add task support: + +```python +from mcp.server import Server +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.types import CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, TASK_REQUIRED + +server = Server("my-server") +server.experimental.enable_tasks() # Registers all task handlers automatically + +@server.list_tools() +async def list_tools(): + return [ + Tool( + name="process_data", + description="Process data asynchronously", + inputSchema={"type": "object", "properties": {"input": {"type": "string"}}}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ) + ] + +@server.call_tool() +async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult: + if name == "process_data": + return await handle_process_data(arguments) + return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True) + +async def handle_process_data(arguments: dict) -> CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Processing...") + result = arguments.get("input", "").upper() + return CallToolResult(content=[TextContent(type="text", text=result)]) + + return await ctx.experimental.run_task(work) +``` + +That's it. `enable_tasks()` automatically: + +- Creates an in-memory task store +- Registers handlers for `tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel` +- Updates server capabilities + +## Tool Declaration + +Tools declare task support via the `execution.taskSupport` field: + +```python +from mcp.types import Tool, ToolExecution, TASK_REQUIRED, TASK_OPTIONAL, TASK_FORBIDDEN + +Tool( + name="my_tool", + inputSchema={"type": "object"}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), # or TASK_OPTIONAL, TASK_FORBIDDEN +) +``` + +| Value | Meaning | +|-------|---------| +| `TASK_REQUIRED` | Tool **must** be called as a task | +| `TASK_OPTIONAL` | Tool supports both sync and task execution | +| `TASK_FORBIDDEN` | Tool **cannot** be called as a task (default) | + +Validate the request matches your tool's requirements: + +```python +@server.call_tool() +async def handle_tool(name: str, arguments: dict): + ctx = server.request_context + + if name == "required_task_tool": + ctx.experimental.validate_task_mode(TASK_REQUIRED) # Raises if not task mode + return await handle_as_task(arguments) + + elif name == "optional_task_tool": + if ctx.experimental.is_task: + return await handle_as_task(arguments) + else: + return handle_sync(arguments) +``` + +## The run_task Pattern + +`run_task()` is the recommended way to execute task work: + +```python +async def handle_my_tool(arguments: dict) -> CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + async def work(task: ServerTaskContext) -> CallToolResult: + # Your work here + return CallToolResult(content=[TextContent(type="text", text="Done")]) + + return await ctx.experimental.run_task(work) +``` + +**What `run_task()` does:** + +1. Creates a task in the store +2. Spawns your work function in the background +3. Returns `CreateTaskResult` immediately +4. Auto-completes the task when your function returns +5. Auto-fails the task if your function raises + +**The `ServerTaskContext` provides:** + +- `task.task_id` - The task identifier +- `task.update_status(message)` - Update progress +- `task.complete(result)` - Explicitly complete (usually automatic) +- `task.fail(error)` - Explicitly fail +- `task.is_cancelled` - Check if cancellation requested + +## Status Updates + +Keep clients informed of progress: + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Starting...") + + for i, item in enumerate(items): + await task.update_status(f"Processing {i+1}/{len(items)}") + await process_item(item) + + await task.update_status("Finalizing...") + return CallToolResult(content=[TextContent(type="text", text="Complete")]) +``` + +Status messages appear in `tasks/get` responses, letting clients show progress to users. + +## Elicitation Within Tasks + +Tasks can request user input via elicitation. This transitions the task to `input_required` status. + +### Form Elicitation + +Collect structured data from the user: + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Waiting for confirmation...") + + result = await task.elicit( + message="Delete these files?", + requestedSchema={ + "type": "object", + "properties": { + "confirm": {"type": "boolean"}, + "reason": {"type": "string"}, + }, + "required": ["confirm"], + }, + ) + + if result.action == "accept" and result.content.get("confirm"): + # User confirmed + return CallToolResult(content=[TextContent(type="text", text="Files deleted")]) + else: + # User declined or cancelled + return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) +``` + +### URL Elicitation + +Direct users to external URLs for OAuth, payments, or other out-of-band flows: + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Waiting for OAuth...") + + result = await task.elicit_url( + message="Please authorize with GitHub", + url="https://github.com/login/oauth/authorize?client_id=...", + elicitation_id="oauth-github-123", + ) + + if result.action == "accept": + # User completed OAuth flow + return CallToolResult(content=[TextContent(type="text", text="Connected to GitHub")]) + else: + return CallToolResult(content=[TextContent(type="text", text="OAuth cancelled")]) +``` + +## Sampling Within Tasks + +Tasks can request LLM completions from the client: + +```python +from mcp.types import SamplingMessage, TextContent + +async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Generating response...") + + result = await task.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text="Write a haiku about coding"), + ) + ], + max_tokens=100, + ) + + haiku = result.content.text if isinstance(result.content, TextContent) else "Error" + return CallToolResult(content=[TextContent(type="text", text=haiku)]) +``` + +Sampling supports additional parameters: + +```python +result = await task.create_message( + messages=[...], + max_tokens=500, + system_prompt="You are a helpful assistant", + temperature=0.7, + stop_sequences=["\n\n"], + model_preferences=ModelPreferences(hints=[ModelHint(name="claude-3")]), +) +``` + +## Cancellation Support + +Check for cancellation in long-running work: + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + for i in range(1000): + if task.is_cancelled: + # Clean up and exit + return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) + + await task.update_status(f"Step {i}/1000") + await process_step(i) + + return CallToolResult(content=[TextContent(type="text", text="Complete")]) +``` + +The SDK's default cancel handler updates the task status. Your work function should check `is_cancelled` periodically. + +## Custom Task Store + +For production, implement `TaskStore` with persistent storage: + +```python +from mcp.shared.experimental.tasks.store import TaskStore +from mcp.types import Task, TaskMetadata, Result + +class RedisTaskStore(TaskStore): + def __init__(self, redis_client): + self.redis = redis_client + + async def create_task(self, metadata: TaskMetadata, task_id: str | None = None) -> Task: + # Create and persist task + ... + + async def get_task(self, task_id: str) -> Task | None: + # Retrieve task from Redis + ... + + async def update_task(self, task_id: str, status: str | None = None, ...) -> Task: + # Update and persist + ... + + async def store_result(self, task_id: str, result: Result) -> None: + # Store result in Redis + ... + + async def get_result(self, task_id: str) -> Result | None: + # Retrieve result + ... + + # ... implement remaining methods +``` + +Use your custom store: + +```python +store = RedisTaskStore(redis_client) +server.experimental.enable_tasks(store=store) +``` + +## Complete Example + +A server with multiple task-supporting tools: + +```python +from mcp.server import Server +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.types import ( + CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, + SamplingMessage, TASK_REQUIRED, +) + +server = Server("task-demo") +server.experimental.enable_tasks() + + +@server.list_tools() +async def list_tools(): + return [ + Tool( + name="confirm_action", + description="Requires user confirmation", + inputSchema={"type": "object", "properties": {"action": {"type": "string"}}}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ), + Tool( + name="generate_text", + description="Generate text via LLM", + inputSchema={"type": "object", "properties": {"prompt": {"type": "string"}}}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ), + ] + + +async def handle_confirm_action(arguments: dict) -> CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + action = arguments.get("action", "unknown action") + + async def work(task: ServerTaskContext) -> CallToolResult: + result = await task.elicit( + message=f"Confirm: {action}?", + requestedSchema={ + "type": "object", + "properties": {"confirm": {"type": "boolean"}}, + "required": ["confirm"], + }, + ) + + if result.action == "accept" and result.content.get("confirm"): + return CallToolResult(content=[TextContent(type="text", text=f"Executed: {action}")]) + return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) + + return await ctx.experimental.run_task(work) + + +async def handle_generate_text(arguments: dict) -> CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + prompt = arguments.get("prompt", "Hello") + + async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Generating...") + + result = await task.create_message( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], + max_tokens=200, + ) + + text = result.content.text if isinstance(result.content, TextContent) else "Error" + return CallToolResult(content=[TextContent(type="text", text=text)]) + + return await ctx.experimental.run_task(work) + + +@server.call_tool() +async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult: + if name == "confirm_action": + return await handle_confirm_action(arguments) + elif name == "generate_text": + return await handle_generate_text(arguments) + return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True) +``` + +## Error Handling in Tasks + +Tasks handle errors automatically, but you can also fail explicitly: + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + try: + result = await risky_operation() + return CallToolResult(content=[TextContent(type="text", text=result)]) + except PermissionError: + await task.fail("Access denied - insufficient permissions") + raise + except TimeoutError: + await task.fail("Operation timed out after 30 seconds") + raise +``` + +When `run_task()` catches an exception, it automatically: + +1. Marks the task as `failed` +2. Sets `statusMessage` to the exception message +3. Propagates the exception (which is caught by the task group) + +For custom error messages, call `task.fail()` before raising. + +## HTTP Transport Example + +For web applications, use the Streamable HTTP transport: + +```python +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import uvicorn +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server import Server +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.types import ( + CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, TASK_REQUIRED, +) + + +server = Server("http-task-server") +server.experimental.enable_tasks() + + +@server.list_tools() +async def list_tools(): + return [ + Tool( + name="long_operation", + description="A long-running operation", + inputSchema={"type": "object", "properties": {"duration": {"type": "number"}}}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ) + ] + + +async def handle_long_operation(arguments: dict) -> CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + duration = arguments.get("duration", 5) + + async def work(task: ServerTaskContext) -> CallToolResult: + import anyio + for i in range(int(duration)): + await task.update_status(f"Step {i+1}/{int(duration)}") + await anyio.sleep(1) + return CallToolResult(content=[TextContent(type="text", text=f"Completed after {duration}s")]) + + return await ctx.experimental.run_task(work) + + +@server.call_tool() +async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult: + if name == "long_operation": + return await handle_long_operation(arguments) + return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True) + + +def create_app(): + session_manager = StreamableHTTPSessionManager(app=server) + + @asynccontextmanager + async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with session_manager.run(): + yield + + return Starlette( + routes=[Mount("/mcp", app=session_manager.handle_request)], + lifespan=lifespan, + ) + + +if __name__ == "__main__": + uvicorn.run(create_app(), host="127.0.0.1", port=8000) +``` + +## Testing Task Servers + +Test task functionality with the SDK's testing utilities: + +```python +import pytest +import anyio +from mcp.client.session import ClientSession +from mcp.types import CallToolResult + + +@pytest.mark.anyio +async def test_task_tool(): + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream(10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream(10) + + async def run_server(): + await server.run( + client_to_server_receive, + server_to_client_send, + server.create_initialization_options(), + ) + + async def run_client(): + async with ClientSession(server_to_client_receive, client_to_server_send) as session: + await session.initialize() + + # Call the tool as a task + result = await session.experimental.call_tool_as_task("my_tool", {"arg": "value"}) + task_id = result.task.taskId + assert result.task.status == "working" + + # Poll until complete + async for status in session.experimental.poll_task(task_id): + if status.status in ("completed", "failed"): + break + + # Get result + final = await session.experimental.get_task_result(task_id, CallToolResult) + assert len(final.content) > 0 + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + tg.start_soon(run_client) +``` + +## Best Practices + +### Keep Work Functions Focused + +```python +# Good: focused work function +async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Validating...") + validate_input(arguments) + + await task.update_status("Processing...") + result = await process_data(arguments) + + return CallToolResult(content=[TextContent(type="text", text=result)]) +``` + +### Check Cancellation in Loops + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + results = [] + for item in large_dataset: + if task.is_cancelled: + return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) + + results.append(await process(item)) + + return CallToolResult(content=[TextContent(type="text", text=str(results))]) +``` + +### Use Meaningful Status Messages + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Connecting to database...") + db = await connect() + + await task.update_status("Fetching records (0/1000)...") + for i, record in enumerate(records): + if i % 100 == 0: + await task.update_status(f"Processing records ({i}/1000)...") + await process(record) + + await task.update_status("Finalizing results...") + return CallToolResult(content=[TextContent(type="text", text="Done")]) +``` + +### Handle Elicitation Responses + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + result = await task.elicit(message="Continue?", requestedSchema={...}) + + match result.action: + case "accept": + # User accepted, process content + return await process_accepted(result.content) + case "decline": + # User explicitly declined + return CallToolResult(content=[TextContent(type="text", text="User declined")]) + case "cancel": + # User cancelled the elicitation + return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) +``` + +## Next Steps + +- [Client Usage](tasks-client.md) - Learn how clients interact with task servers +- [Tasks Overview](tasks.md) - Review lifecycle and concepts diff --git a/docs/experimental/tasks.md b/docs/experimental/tasks.md new file mode 100644 index 0000000000..2d4d06a025 --- /dev/null +++ b/docs/experimental/tasks.md @@ -0,0 +1,188 @@ +# Tasks + +!!! warning "Experimental" + + Tasks are an experimental feature tracking the draft MCP specification. + The API may change without notice. + +Tasks enable asynchronous request handling in MCP. Instead of blocking until an operation completes, the receiver creates a task, returns immediately, and the requestor polls for the result. + +## When to Use Tasks + +Tasks are designed for operations that: + +- Take significant time (seconds to minutes) +- Need progress updates during execution +- Require user input mid-execution (elicitation, sampling) +- Should run without blocking the requestor + +Common use cases: + +- Long-running data processing +- Multi-step workflows with user confirmation +- LLM-powered operations requiring sampling +- OAuth flows requiring user browser interaction + +## Task Lifecycle + +```text + ┌─────────────┐ + │ working │ + └──────┬──────┘ + │ + ┌────────────┼────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌────────────┐ ┌───────────┐ ┌───────────┐ + │ completed │ │ failed │ │ cancelled │ + └────────────┘ └───────────┘ └───────────┘ + ▲ + │ + ┌────────┴────────┐ + │ input_required │◄──────┐ + └────────┬────────┘ │ + │ │ + └────────────────┘ +``` + +| Status | Description | +|--------|-------------| +| `working` | Task is being processed | +| `input_required` | Receiver needs input from requestor (elicitation/sampling) | +| `completed` | Task finished successfully | +| `failed` | Task encountered an error | +| `cancelled` | Task was cancelled by requestor | + +Terminal states (`completed`, `failed`, `cancelled`) are final—tasks cannot transition out of them. + +## Bidirectional Flow + +Tasks work in both directions: + +**Client → Server** (most common): + +```text +Client Server + │ │ + │── tools/call (task) ──────────────>│ Creates task + │<── CreateTaskResult ───────────────│ + │ │ + │── tasks/get ──────────────────────>│ + │<── status: working ────────────────│ + │ │ ... work continues ... + │── tasks/get ──────────────────────>│ + │<── status: completed ──────────────│ + │ │ + │── tasks/result ───────────────────>│ + │<── CallToolResult ─────────────────│ +``` + +**Server → Client** (for elicitation/sampling): + +```text +Server Client + │ │ + │── elicitation/create (task) ──────>│ Creates task + │<── CreateTaskResult ───────────────│ + │ │ + │── tasks/get ──────────────────────>│ + │<── status: working ────────────────│ + │ │ ... user interaction ... + │── tasks/get ──────────────────────>│ + │<── status: completed ──────────────│ + │ │ + │── tasks/result ───────────────────>│ + │<── ElicitResult ───────────────────│ +``` + +## Key Concepts + +### Task Metadata + +When augmenting a request with task execution, include `TaskMetadata`: + +```python +from mcp.types import TaskMetadata + +task = TaskMetadata(ttl=60000) # TTL in milliseconds +``` + +The `ttl` (time-to-live) specifies how long the task and result are retained after completion. + +### Task Store + +Servers persist task state in a `TaskStore`. The SDK provides `InMemoryTaskStore` for development: + +```python +from mcp.shared.experimental.tasks import InMemoryTaskStore + +store = InMemoryTaskStore() +``` + +For production, implement `TaskStore` with a database or distributed cache. + +### Capabilities + +Both servers and clients declare task support through capabilities: + +**Server capabilities:** + +- `tasks.requests.tools.call` - Server accepts task-augmented tool calls + +**Client capabilities:** + +- `tasks.requests.sampling.createMessage` - Client accepts task-augmented sampling +- `tasks.requests.elicitation.create` - Client accepts task-augmented elicitation + +The SDK manages these automatically when you enable task support. + +## Quick Example + +**Server** (simplified API): + +```python +from mcp.server import Server +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.types import CallToolResult, TextContent, TASK_REQUIRED + +server = Server("my-server") +server.experimental.enable_tasks() # One-line setup + +@server.call_tool() +async def handle_tool(name: str, arguments: dict): + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + async def work(task: ServerTaskContext): + await task.update_status("Processing...") + # ... do work ... + return CallToolResult(content=[TextContent(type="text", text="Done!")]) + + return await ctx.experimental.run_task(work) +``` + +**Client:** + +```python +from mcp.client.session import ClientSession +from mcp.types import CallToolResult + +async with ClientSession(read, write) as session: + await session.initialize() + + # Call tool as task + result = await session.experimental.call_tool_as_task("my_tool", {"arg": "value"}) + task_id = result.task.taskId + + # Poll until done + async for status in session.experimental.poll_task(task_id): + print(f"Status: {status.status}") + + # Get result + final = await session.experimental.get_task_result(task_id, CallToolResult) +``` + +## Next Steps + +- [Server Implementation](tasks-server.md) - Build task-supporting servers +- [Client Usage](tasks-client.md) - Call and poll tasks from clients diff --git a/docs/index.md b/docs/index.md index 42ad9ca0ca..eb5ddf4000 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,67 @@ -# MCP Server +# MCP Python SDK -This is the MCP Server implementation in Python. +The **Model Context Protocol (MCP)** allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. -It only contains the [API Reference](api.md) for the time being. +This Python SDK implements the full MCP specification, making it easy to: + +- **Build MCP servers** that expose resources, prompts, and tools +- **Create MCP clients** that can connect to any MCP server +- **Use standard transports** like stdio, SSE, and Streamable HTTP + +If you want to read more about the specification, please visit the [MCP documentation](https://modelcontextprotocol.io). + +## Quick Example + +Here's a simple MCP server that exposes a tool, resource, and prompt: + +```python title="server.py" +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Test Server", json_response=True) + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b + + +@mcp.resource("greeting://{name}") +def get_greeting(name: str) -> str: + """Get a personalized greeting""" + return f"Hello, {name}!" + + +@mcp.prompt() +def greet_user(name: str, style: str = "friendly") -> str: + """Generate a greeting prompt""" + return f"Write a {style} greeting for someone named {name}." + + +if __name__ == "__main__": + mcp.run(transport="streamable-http") +``` + +Run the server: + +```bash +uv run --with mcp server.py +``` + +Then open the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) and connect to `http://localhost:8000/mcp`: + +```bash +npx -y @modelcontextprotocol/inspector +``` + +## Getting Started + + +1. **[Install](installation.md)** the MCP SDK +2. **[Learn concepts](concepts.md)** - understand the three primitives and architecture +3. **[Explore authorization](authorization.md)** - add security to your servers +4. **[Use low-level APIs](low-level-server.md)** - for advanced customization + +## API Reference + +Full API documentation is available in the [API Reference](api.md). diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000000..6e20706a84 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,31 @@ +# Installation + +The Python SDK is available on PyPI as [`mcp`](https://pypi.org/project/mcp/) so installation is as simple as: + +=== "pip" + + ```bash + pip install mcp + ``` +=== "uv" + + ```bash + uv add mcp + ``` + +The following dependencies are automatically installed: + +- [`httpx`](https://pypi.org/project/httpx/): HTTP client to handle HTTP Streamable and SSE transports. +- [`httpx-sse`](https://pypi.org/project/httpx-sse/): HTTP client to handle SSE transport. +- [`pydantic`](https://pypi.org/project/pydantic/): Types, JSON schema generation, data validation, and [more](https://docs.pydantic.dev/latest/). +- [`starlette`](https://pypi.org/project/starlette/): Web framework used to build the HTTP transport endpoints. +- [`python-multipart`](https://pypi.org/project/python-multipart/): Handle HTTP body parsing. +- [`sse-starlette`](https://pypi.org/project/sse-starlette/): Server-Sent Events for Starlette, used to build the SSE transport endpoint. +- [`pydantic-settings`](https://pypi.org/project/pydantic-settings/): Settings management used in FastMCP. +- [`uvicorn`](https://pypi.org/project/uvicorn/): ASGI server used to run the HTTP transport endpoints. +- [`jsonschema`](https://pypi.org/project/jsonschema/): JSON schema validation. +- [`pywin32`](https://pypi.org/project/pywin32/): Windows specific dependencies for the CLI tools. + +This package has the following optional groups: + +- `cli`: Installs `typer` and `python-dotenv` for the MCP CLI tools. diff --git a/docs/low-level-server.md b/docs/low-level-server.md new file mode 100644 index 0000000000..a5b4f3df33 --- /dev/null +++ b/docs/low-level-server.md @@ -0,0 +1,5 @@ +# Low-Level Server + +!!! warning "Under Construction" + + This page is currently being written. Check back soon for complete documentation. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000000..8d84449893 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,78 @@ +# Testing MCP Servers + +If you call yourself a developer, you will want to test your MCP server. +The Python SDK offers the `create_connected_server_and_client_session` function to create a session +using an in-memory transport. I know, I know, the name is too long... We are working on improving it. + +Anyway, let's assume you have a simple server with a single tool: + +```python title="server.py" +from mcp.server import FastMCP + +app = FastMCP("Calculator") + +@app.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" # (1)! + return a + b +``` + +1. The docstring is automatically added as the description of the tool. + +To run the below test, you'll need to install the following dependencies: + +=== "pip" + ```bash + pip install inline-snapshot pytest + ``` + +=== "uv" + ```bash + uv add inline-snapshot pytest + ``` + +!!! info + I think [`pytest`](https://docs.pytest.org/en/stable/) is a pretty standard testing framework, + so I won't go into details here. + + The [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is a library that allows + you to take snapshots of the output of your tests. Which makes it easier to create tests for your + server - you don't need to use it, but we are spreading the word for best practices. + +```python title="test_server.py" +from collections.abc import AsyncGenerator + +import pytest +from inline_snapshot import snapshot +from mcp.client.session import ClientSession +from mcp.shared.memory import create_connected_server_and_client_session +from mcp.types import CallToolResult, TextContent + +from server import app + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client_session() -> AsyncGenerator[ClientSession]: + async with create_connected_server_and_client_session(app, raise_exceptions=True) as _session: + yield _session + + +@pytest.mark.anyio +async def test_call_add_tool(client_session: ClientSession): + result = await client_session.call_tool("add", {"a": 1, "b": 2}) + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structuredContent={"result": 3}, + ) + ) +``` + +1. If you are using `trio`, you should set `"trio"` as the `anyio_backend`. Check more information in the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on). + +There you go! You can now extend your tests to cover more scenarios. diff --git a/examples/clients/conformance-auth-client/README.md b/examples/clients/conformance-auth-client/README.md new file mode 100644 index 0000000000..312a992d0a --- /dev/null +++ b/examples/clients/conformance-auth-client/README.md @@ -0,0 +1,49 @@ +# MCP Conformance Auth Client + +A Python OAuth client designed for use with the MCP conformance test framework. + +## Overview + +This client implements OAuth authentication for MCP and is designed to work automatically with the conformance test framework without requiring user interaction. It programmatically fetches authorization URLs and extracts auth codes from redirects. + +## Installation + +```bash +cd examples/clients/conformance-auth-client +uv sync +``` + +## Usage with Conformance Tests + +Run the auth conformance tests against this Python client: + +```bash +# From the conformance repository +npx @modelcontextprotocol/conformance client \ + --command "uv run --directory /path/to/python-sdk/examples/clients/conformance-auth-client python -m mcp_conformance_auth_client" \ + --scenario auth/basic-dcr +``` + +Available auth test scenarios: + +- `auth/basic-dcr` - Tests OAuth Dynamic Client Registration flow +- `auth/basic-metadata-var1` - Tests OAuth with authorization metadata + +## How It Works + +Unlike interactive OAuth clients that open a browser for user authentication, this client: + +1. Receives the authorization URL from the OAuth provider +2. Makes an HTTP request to that URL directly (without following redirects) +3. Extracts the authorization code from the redirect response +4. Uses the code to complete the OAuth token exchange + +This allows the conformance test framework's mock OAuth server to automatically provide auth codes without human interaction. + +## Direct Usage + +You can also run the client directly: + +```bash +uv run python -m mcp_conformance_auth_client http://localhost:3000/mcp +``` diff --git a/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__init__.py b/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__init__.py new file mode 100644 index 0000000000..ba8679e3ac --- /dev/null +++ b/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__init__.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +""" +MCP OAuth conformance test client. + +This client is designed to work with the MCP conformance test framework. +It automatically handles OAuth flows without user interaction by programmatically +fetching the authorization URL and extracting the auth code from the redirect. + +Usage: + python -m mcp_conformance_auth_client + +Environment Variables: + MCP_CONFORMANCE_CONTEXT - JSON object containing test credentials: + { + "client_id": "...", + "client_secret": "...", # For client_secret_basic flow + "private_key_pem": "...", # For private_key_jwt flow + "signing_algorithm": "ES256" # Optional, defaults to ES256 + } + +Scenarios: + auth/* - Authorization code flow scenarios (default behavior) + auth/client-credentials-jwt - Client credentials with JWT authentication (SEP-1046) + auth/client-credentials-basic - Client credentials with client_secret_basic +""" + +import asyncio +import json +import logging +import os +import sys +from urllib.parse import ParseResult, parse_qs, urlparse + +import httpx +from mcp import ClientSession +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.auth.extensions.client_credentials import ( + ClientCredentialsOAuthProvider, + PrivateKeyJWTOAuthProvider, + SignedJWTParameters, +) +from mcp.client.streamable_http import streamablehttp_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken +from pydantic import AnyUrl + + +def get_conformance_context() -> dict: + """Load conformance test context from MCP_CONFORMANCE_CONTEXT environment variable.""" + context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT") + if not context_json: + raise RuntimeError( + "MCP_CONFORMANCE_CONTEXT environment variable not set. " + "Expected JSON with client_id, client_secret, and/or private_key_pem." + ) + try: + return json.loads(context_json) + except json.JSONDecodeError as e: + raise RuntimeError(f"Failed to parse MCP_CONFORMANCE_CONTEXT as JSON: {e}") from e + + +# Set up logging to stderr (stdout is for conformance test output) +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + stream=sys.stderr, +) +logger = logging.getLogger(__name__) + + +class InMemoryTokenStorage(TokenStorage): + """Simple in-memory token storage for conformance testing.""" + + def __init__(self): + self._tokens: OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info + + +class ConformanceOAuthCallbackHandler: + """ + OAuth callback handler that automatically fetches the authorization URL + and extracts the auth code, without requiring user interaction. + + This mimics the behavior of the TypeScript ConformanceOAuthProvider. + """ + + def __init__(self): + self._auth_code: str | None = None + self._state: str | None = None + + async def handle_redirect(self, authorization_url: str) -> None: + """ + Fetch the authorization URL and extract the auth code from the redirect. + + The conformance test server returns a redirect with the auth code, + so we can capture it programmatically. + """ + logger.debug(f"Fetching authorization URL: {authorization_url}") + + async with httpx.AsyncClient() as client: + response = await client.get( + authorization_url, + follow_redirects=False, # Don't follow redirects automatically + ) + + # Check for redirect response + if response.status_code in (301, 302, 303, 307, 308): + location = response.headers.get("location") + if location: + redirect_url: ParseResult = urlparse(location) + query_params: dict[str, list[str]] = parse_qs(redirect_url.query) + + if "code" in query_params: + self._auth_code = query_params["code"][0] + state_values = query_params.get("state") + self._state = state_values[0] if state_values else None + logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...") + return + else: + raise RuntimeError(f"No auth code in redirect URL: {location}") + else: + raise RuntimeError(f"No redirect location received from {authorization_url}") + else: + raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}") + + async def handle_callback(self) -> tuple[str, str | None]: + """Return the captured auth code and state, then clear them for potential reuse.""" + if self._auth_code is None: + raise RuntimeError("No authorization code available - was handle_redirect called?") + auth_code = self._auth_code + state = self._state + # Clear the stored values so the next auth flow gets fresh ones + self._auth_code = None + self._state = None + return auth_code, state + + +async def run_authorization_code_client(server_url: str) -> None: + """ + Run the conformance test client with authorization code flow. + + This function: + 1. Connects to the MCP server with OAuth authorization code flow + 2. Initializes the session + 3. Lists available tools + 4. Calls a test tool + """ + logger.debug(f"Starting conformance auth client (authorization_code) for {server_url}") + + # Create callback handler that will automatically fetch auth codes + callback_handler = ConformanceOAuthCallbackHandler() + + # Create OAuth authentication handler + oauth_auth = OAuthClientProvider( + server_url=server_url, + client_metadata=OAuthClientMetadata( + client_name="conformance-auth-client", + redirect_uris=[AnyUrl("http://localhost:3000/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + ), + storage=InMemoryTokenStorage(), + redirect_handler=callback_handler.handle_redirect, + callback_handler=callback_handler.handle_callback, + ) + + await _run_session(server_url, oauth_auth) + + +async def run_client_credentials_jwt_client(server_url: str) -> None: + """ + Run the conformance test client with client credentials flow using private_key_jwt (SEP-1046). + + This function: + 1. Connects to the MCP server with OAuth client_credentials grant + 2. Uses private_key_jwt authentication with credentials from MCP_CONFORMANCE_CONTEXT + 3. Initializes the session + 4. Lists available tools + 5. Calls a test tool + """ + logger.debug(f"Starting conformance auth client (client_credentials_jwt) for {server_url}") + + # Load credentials from environment + context = get_conformance_context() + client_id = context.get("client_id") + private_key_pem = context.get("private_key_pem") + signing_algorithm = context.get("signing_algorithm", "ES256") + + if not client_id: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'") + if not private_key_pem: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'private_key_pem'") + + # Create JWT parameters for SDK-signed assertions + jwt_params = SignedJWTParameters( + issuer=client_id, + subject=client_id, + signing_algorithm=signing_algorithm, + signing_key=private_key_pem, + ) + + # Create OAuth provider for client_credentials with private_key_jwt + oauth_auth = PrivateKeyJWTOAuthProvider( + server_url=server_url, + storage=InMemoryTokenStorage(), + client_id=client_id, + assertion_provider=jwt_params.create_assertion_provider(), + ) + + await _run_session(server_url, oauth_auth) + + +async def run_client_credentials_basic_client(server_url: str) -> None: + """ + Run the conformance test client with client credentials flow using client_secret_basic. + + This function: + 1. Connects to the MCP server with OAuth client_credentials grant + 2. Uses client_secret_basic authentication with credentials from MCP_CONFORMANCE_CONTEXT + 3. Initializes the session + 4. Lists available tools + 5. Calls a test tool + """ + logger.debug(f"Starting conformance auth client (client_credentials_basic) for {server_url}") + + # Load credentials from environment + context = get_conformance_context() + client_id = context.get("client_id") + client_secret = context.get("client_secret") + + if not client_id: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'") + if not client_secret: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'") + + # Create OAuth provider for client_credentials with client_secret_basic + oauth_auth = ClientCredentialsOAuthProvider( + server_url=server_url, + storage=InMemoryTokenStorage(), + client_id=client_id, + client_secret=client_secret, + token_endpoint_auth_method="client_secret_basic", + ) + + await _run_session(server_url, oauth_auth) + + +async def _run_session(server_url: str, oauth_auth: OAuthClientProvider) -> None: + """Common session logic for all OAuth flows.""" + # Connect using streamable HTTP transport with OAuth + async with streamablehttp_client( + url=server_url, + auth=oauth_auth, + timeout=30.0, + sse_read_timeout=60.0, + ) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the session + await session.initialize() + logger.debug("Successfully connected and initialized MCP session") + + # List tools + tools_result = await session.list_tools() + logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}") + + # Call test tool (expected by conformance tests) + try: + result = await session.call_tool("test-tool", {}) + logger.debug(f"Called test-tool, result: {result}") + except Exception as e: + logger.debug(f"Tool call result/error: {e}") + + logger.debug("Connection closed successfully") + + +def main() -> None: + """Main entry point for the conformance auth client.""" + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + print("", file=sys.stderr) + print("Scenarios:", file=sys.stderr) + print(" auth/* - Authorization code flow (default)", file=sys.stderr) + print(" auth/client-credentials-jwt - Client credentials with JWT auth (SEP-1046)", file=sys.stderr) + print(" auth/client-credentials-basic - Client credentials with client_secret_basic", file=sys.stderr) + sys.exit(1) + + scenario = sys.argv[1] + server_url = sys.argv[2] + + try: + if scenario == "auth/client-credentials-jwt": + asyncio.run(run_client_credentials_jwt_client(server_url)) + elif scenario == "auth/client-credentials-basic": + asyncio.run(run_client_credentials_basic_client(server_url)) + else: + # Default to authorization code flow for all other auth/* scenarios + asyncio.run(run_authorization_code_client(server_url)) + except Exception: + logger.exception("Client failed") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__main__.py b/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__main__.py new file mode 100644 index 0000000000..1b8f8acb09 --- /dev/null +++ b/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__main__.py @@ -0,0 +1,6 @@ +"""Allow running the module with python -m.""" + +from . import main + +if __name__ == "__main__": + main() diff --git a/examples/clients/conformance-auth-client/pyproject.toml b/examples/clients/conformance-auth-client/pyproject.toml new file mode 100644 index 0000000000..3d03b4d4a1 --- /dev/null +++ b/examples/clients/conformance-auth-client/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-conformance-auth-client" +version = "0.1.0" +description = "OAuth conformance test client for MCP" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic" }] +keywords = ["mcp", "oauth", "client", "auth", "conformance", "testing"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["mcp", "httpx>=0.28.1"] + +[project.scripts] +mcp-conformance-auth-client = "mcp_conformance_auth_client:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_conformance_auth_client"] + +[tool.pyright] +include = ["mcp_conformance_auth_client"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/clients/simple-auth-client/README.md b/examples/clients/simple-auth-client/README.md new file mode 100644 index 0000000000..3e92f29479 --- /dev/null +++ b/examples/clients/simple-auth-client/README.md @@ -0,0 +1,74 @@ +# Simple Auth Client Example + +A demonstration of how to use the MCP Python SDK with OAuth authentication over streamable HTTP or SSE transport. + +## Features + +- OAuth 2.0 authentication with PKCE +- Support for both StreamableHTTP and SSE transports +- Interactive command-line interface + +## Installation + +```bash +cd examples/clients/simple-auth-client +uv sync --reinstall +``` + +## Usage + +### 1. Start an MCP server with OAuth support + +```bash +# Example with mcp-simple-auth +cd path/to/mcp-simple-auth +uv run mcp-simple-auth --transport streamable-http --port 3001 +``` + +### 2. Run the client + +```bash +uv run mcp-simple-auth-client + +# Or with custom server URL +MCP_SERVER_PORT=3001 uv run mcp-simple-auth-client + +# Use SSE transport +MCP_TRANSPORT_TYPE=sse uv run mcp-simple-auth-client +``` + +### 3. Complete OAuth flow + +The client will open your browser for authentication. After completing OAuth, you can use commands: + +- `list` - List available tools +- `call [args]` - Call a tool with optional JSON arguments +- `quit` - Exit + +## Example + +```markdown +🔐 Simple MCP Auth Client +Connecting to: http://localhost:3001 + +Please visit the following URL to authorize the application: +http://localhost:3001/authorize?response_type=code&client_id=... + +✅ Connected to MCP server at http://localhost:3001 + +mcp> list +📋 Available tools: +1. echo - Echo back the input text + +mcp> call echo {"text": "Hello, world!"} +🔧 Tool 'echo' result: +Hello, world! + +mcp> quit +👋 Goodbye! +``` + +## Configuration + +- `MCP_SERVER_PORT` - Server URL (default: 8000) +- `MCP_TRANSPORT_TYPE` - Transport type: `streamable-http` (default) or `sse` diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py new file mode 100644 index 0000000000..06eb1f29d1 --- /dev/null +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py @@ -0,0 +1 @@ +"""Simple OAuth client for MCP simple-auth server.""" diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py new file mode 100644 index 0000000000..0223b72394 --- /dev/null +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +""" +Simple MCP client example with OAuth authentication support. + +This client connects to an MCP server using streamable HTTP transport with OAuth. + +""" + +import asyncio +import os +import threading +import time +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any +from urllib.parse import parse_qs, urlparse + +import httpx +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.session import ClientSession +from mcp.client.sse import sse_client +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class InMemoryTokenStorage(TokenStorage): + """Simple in-memory token storage implementation.""" + + def __init__(self): + self._tokens: OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info + + +class CallbackHandler(BaseHTTPRequestHandler): + """Simple HTTP handler to capture OAuth callback.""" + + def __init__(self, request, client_address, server, callback_data): + """Initialize with callback data storage.""" + self.callback_data = callback_data + super().__init__(request, client_address, server) + + def do_GET(self): + """Handle GET request from OAuth redirect.""" + parsed = urlparse(self.path) + query_params = parse_qs(parsed.query) + + if "code" in query_params: + self.callback_data["authorization_code"] = query_params["code"][0] + self.callback_data["state"] = query_params.get("state", [None])[0] + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(b""" + + +

Authorization Successful!

+

You can close this window and return to the terminal.

+ + + + """) + elif "error" in query_params: + self.callback_data["error"] = query_params["error"][0] + self.send_response(400) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + f""" + + +

Authorization Failed

+

Error: {query_params["error"][0]}

+

You can close this window and return to the terminal.

+ + + """.encode() + ) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + """Suppress default logging.""" + pass + + +class CallbackServer: + """Simple server to handle OAuth callbacks.""" + + def __init__(self, port=3000): + self.port = port + self.server = None + self.thread = None + self.callback_data = {"authorization_code": None, "state": None, "error": None} + + def _create_handler_with_data(self): + """Create a handler class with access to callback data.""" + callback_data = self.callback_data + + class DataCallbackHandler(CallbackHandler): + def __init__(self, request, client_address, server): + super().__init__(request, client_address, server, callback_data) + + return DataCallbackHandler + + def start(self): + """Start the callback server in a background thread.""" + handler_class = self._create_handler_with_data() + self.server = HTTPServer(("localhost", self.port), handler_class) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + print(f"🖥️ Started callback server on http://localhost:{self.port}") + + def stop(self): + """Stop the callback server.""" + if self.server: + self.server.shutdown() + self.server.server_close() + if self.thread: + self.thread.join(timeout=1) + + def wait_for_callback(self, timeout=300): + """Wait for OAuth callback with timeout.""" + start_time = time.time() + while time.time() - start_time < timeout: + if self.callback_data["authorization_code"]: + return self.callback_data["authorization_code"] + elif self.callback_data["error"]: + raise Exception(f"OAuth error: {self.callback_data['error']}") + time.sleep(0.1) + raise Exception("Timeout waiting for OAuth callback") + + def get_state(self): + """Get the received state parameter.""" + return self.callback_data["state"] + + +class SimpleAuthClient: + """Simple MCP client with auth support.""" + + def __init__( + self, + server_url: str, + transport_type: str = "streamable-http", + client_metadata_url: str | None = None, + ): + self.server_url = server_url + self.transport_type = transport_type + self.client_metadata_url = client_metadata_url + self.session: ClientSession | None = None + + async def connect(self): + """Connect to the MCP server.""" + print(f"🔗 Attempting to connect to {self.server_url}...") + + try: + callback_server = CallbackServer(port=3030) + callback_server.start() + + async def callback_handler() -> tuple[str, str | None]: + """Wait for OAuth callback and return auth code and state.""" + print("⏳ Waiting for authorization callback...") + try: + auth_code = callback_server.wait_for_callback(timeout=300) + return auth_code, callback_server.get_state() + finally: + callback_server.stop() + + client_metadata_dict = { + "client_name": "Simple Auth Client", + "redirect_uris": ["http://localhost:3030/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + } + + async def _default_redirect_handler(authorization_url: str) -> None: + """Default redirect handler that opens the URL in a browser.""" + print(f"Opening browser for authorization: {authorization_url}") + webbrowser.open(authorization_url) + + # Create OAuth authentication handler using the new interface + # Use client_metadata_url to enable CIMD when the server supports it + oauth_auth = OAuthClientProvider( + server_url=self.server_url.replace("/mcp", ""), + client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict), + storage=InMemoryTokenStorage(), + redirect_handler=_default_redirect_handler, + callback_handler=callback_handler, + client_metadata_url=self.client_metadata_url, + ) + + # Create transport with auth handler based on transport type + if self.transport_type == "sse": + print("📡 Opening SSE transport connection with auth...") + async with sse_client( + url=self.server_url, + auth=oauth_auth, + timeout=60.0, + ) as (read_stream, write_stream): + await self._run_session(read_stream, write_stream, None) + else: + print("📡 Opening StreamableHTTP transport connection with auth...") + async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: + async with streamable_http_client( + url=self.server_url, + http_client=custom_client, + ) as (read_stream, write_stream, get_session_id): + await self._run_session(read_stream, write_stream, get_session_id) + + except Exception as e: + print(f"❌ Failed to connect: {e}") + import traceback + + traceback.print_exc() + + async def _run_session(self, read_stream, write_stream, get_session_id): + """Run the MCP session with the given streams.""" + print("🤝 Initializing MCP session...") + async with ClientSession(read_stream, write_stream) as session: + self.session = session + print("⚡ Starting session initialization...") + await session.initialize() + print("✨ Session initialization complete!") + + print(f"\n✅ Connected to MCP server at {self.server_url}") + if get_session_id: + session_id = get_session_id() + if session_id: + print(f"Session ID: {session_id}") + + # Run interactive loop + await self.interactive_loop() + + async def list_tools(self): + """List available tools from the server.""" + if not self.session: + print("❌ Not connected to server") + return + + try: + result = await self.session.list_tools() + if hasattr(result, "tools") and result.tools: + print("\n📋 Available tools:") + for i, tool in enumerate(result.tools, 1): + print(f"{i}. {tool.name}") + if tool.description: + print(f" Description: {tool.description}") + print() + else: + print("No tools available") + except Exception as e: + print(f"❌ Failed to list tools: {e}") + + async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None): + """Call a specific tool.""" + if not self.session: + print("❌ Not connected to server") + return + + try: + result = await self.session.call_tool(tool_name, arguments or {}) + print(f"\n🔧 Tool '{tool_name}' result:") + if hasattr(result, "content"): + for content in result.content: + if content.type == "text": + print(content.text) + else: + print(content) + else: + print(result) + except Exception as e: + print(f"❌ Failed to call tool '{tool_name}': {e}") + + async def interactive_loop(self): + """Run interactive command loop.""" + print("\n🎯 Interactive MCP Client") + print("Commands:") + print(" list - List available tools") + print(" call [args] - Call a tool") + print(" quit - Exit the client") + print() + + while True: + try: + command = input("mcp> ").strip() + + if not command: + continue + + if command == "quit": + break + + elif command == "list": + await self.list_tools() + + elif command.startswith("call "): + parts = command.split(maxsplit=2) + tool_name = parts[1] if len(parts) > 1 else "" + + if not tool_name: + print("❌ Please specify a tool name") + continue + + # Parse arguments (simple JSON-like format) + arguments = {} + if len(parts) > 2: + import json + + try: + arguments = json.loads(parts[2]) + except json.JSONDecodeError: + print("❌ Invalid arguments format (expected JSON)") + continue + + await self.call_tool(tool_name, arguments) + + else: + print("❌ Unknown command. Try 'list', 'call ', or 'quit'") + + except KeyboardInterrupt: + print("\n\n👋 Goodbye!") + break + except EOFError: + break + + +async def main(): + """Main entry point.""" + # Default server URL - can be overridden with environment variable + # Most MCP streamable HTTP servers use /mcp as the endpoint + server_url = os.getenv("MCP_SERVER_PORT", 8000) + transport_type = os.getenv("MCP_TRANSPORT_TYPE", "streamable-http") + client_metadata_url = os.getenv("MCP_CLIENT_METADATA_URL") + server_url = ( + f"http://localhost:{server_url}/mcp" + if transport_type == "streamable-http" + else f"http://localhost:{server_url}/sse" + ) + + print("🚀 Simple MCP Auth Client") + print(f"Connecting to: {server_url}") + print(f"Transport type: {transport_type}") + if client_metadata_url: + print(f"Client metadata URL: {client_metadata_url}") + + # Start connection flow - OAuth will be handled automatically + client = SimpleAuthClient(server_url, transport_type, client_metadata_url) + await client.connect() + + +def cli(): + """CLI entry point for uv script.""" + asyncio.run(main()) + + +if __name__ == "__main__": + cli() diff --git a/examples/clients/simple-auth-client/pyproject.toml b/examples/clients/simple-auth-client/pyproject.toml new file mode 100644 index 0000000000..46aba8dc12 --- /dev/null +++ b/examples/clients/simple-auth-client/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-auth-client" +version = "0.1.0" +description = "A simple OAuth client for the MCP simple-auth server" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic" }] +keywords = ["mcp", "oauth", "client", "auth"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["click>=8.2.0", "mcp"] + +[project.scripts] +mcp-simple-auth-client = "mcp_simple_auth_client.main:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_auth_client"] + +[tool.pyright] +include = ["mcp_simple_auth_client"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/clients/simple-chatbot/README.MD b/examples/clients/simple-chatbot/README.MD index 683e4f3f52..482109f97b 100644 --- a/examples/clients/simple-chatbot/README.MD +++ b/examples/clients/simple-chatbot/README.MD @@ -26,9 +26,11 @@ This example demonstrates how to integrate the Model Context Protocol (MCP) into LLM_API_KEY=your_api_key_here ``` + **Note:** The current implementation is configured to use the Groq API endpoint (`https://api.groq.com/openai/v1/chat/completions`) with the `llama-3.2-90b-vision-preview` model. If you plan to use a different LLM provider, you'll need to modify the `LLMClient` class in `main.py` to use the appropriate endpoint URL and model parameters. + 3. **Configure servers:** - The `servers_config.json` follows the same structure as Claude Desktop, allowing for easy integration of multiple servers. + The `servers_config.json` follows the same structure as Claude Desktop, allowing for easy integration of multiple servers. Here's an example: ```json @@ -45,9 +47,11 @@ This example demonstrates how to integrate the Model Context Protocol (MCP) into } } ``` + Environment variables are supported as well. Pass them as you would with the Claude Desktop App. Example: + ```json { "mcpServers": { @@ -71,7 +75,7 @@ This example demonstrates how to integrate the Model Context Protocol (MCP) into ``` 2. **Interact with the assistant:** - + The assistant will automatically detect available tools and can respond to queries based on the tools provided by the configured servers. 3. **Exit the session:** @@ -85,6 +89,7 @@ This example demonstrates how to integrate the Model Context Protocol (MCP) into - **Server Integration**: Supports any MCP-compatible server, tested with various server implementations including Uvicorn and Node.js. ### Class Structure + - **Configuration**: Manages environment variables and server configurations - **Server**: Handles MCP server initialization, tool discovery, and execution - **Tool**: Represents individual tools with their properties and formatting @@ -106,5 +111,3 @@ This example demonstrates how to integrate the Model Context Protocol (MCP) into - If it's a direct response → return to user - Tool results are sent back to LLM for interpretation - Final response is presented to user - - diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py index a06e593bd9..78a81a4d9f 100644 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py @@ -12,9 +12,7 @@ from mcp.client.stdio import stdio_client # Configure logging -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" -) +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") class Configuration: @@ -75,29 +73,19 @@ def __init__(self, name: str, config: dict[str, Any]) -> None: async def initialize(self) -> None: """Initialize the server connection.""" - command = ( - shutil.which("npx") - if self.config["command"] == "npx" - else self.config["command"] - ) + command = shutil.which("npx") if self.config["command"] == "npx" else self.config["command"] if command is None: raise ValueError("The command must be a valid string and cannot be None.") server_params = StdioServerParameters( command=command, args=self.config["args"], - env={**os.environ, **self.config["env"]} - if self.config.get("env") - else None, + env={**os.environ, **self.config["env"]} if self.config.get("env") else None, ) try: - stdio_transport = await self.exit_stack.enter_async_context( - stdio_client(server_params) - ) + stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) read, write = stdio_transport - session = await self.exit_stack.enter_async_context( - ClientSession(read, write) - ) + session = await self.exit_stack.enter_async_context(ClientSession(read, write)) await session.initialize() self.session = session except Exception as e: @@ -122,10 +110,7 @@ async def list_tools(self) -> list[Any]: for item in tools_response: if isinstance(item, tuple) and item[0] == "tools": - tools.extend( - Tool(tool.name, tool.description, tool.inputSchema) - for tool in item[1] - ) + tools.extend(Tool(tool.name, tool.description, tool.inputSchema, tool.title) for tool in item[1]) return tools @@ -164,9 +149,7 @@ async def execute_tool( except Exception as e: attempt += 1 - logging.warning( - f"Error executing tool: {e}. Attempt {attempt} of {retries}." - ) + logging.warning(f"Error executing tool: {e}. Attempt {attempt} of {retries}.") if attempt < retries: logging.info(f"Retrying in {delay} seconds...") await asyncio.sleep(delay) @@ -189,9 +172,14 @@ class Tool: """Represents a tool with its properties and formatting.""" def __init__( - self, name: str, description: str, input_schema: dict[str, Any] + self, + name: str, + description: str, + input_schema: dict[str, Any], + title: str | None = None, ) -> None: self.name: str = name + self.title: str | None = title self.description: str = description self.input_schema: dict[str, Any] = input_schema @@ -204,20 +192,25 @@ def format_for_llm(self) -> str: args_desc = [] if "properties" in self.input_schema: for param_name, param_info in self.input_schema["properties"].items(): - arg_desc = ( - f"- {param_name}: {param_info.get('description', 'No description')}" - ) + arg_desc = f"- {param_name}: {param_info.get('description', 'No description')}" if param_name in self.input_schema.get("required", []): arg_desc += " (required)" args_desc.append(arg_desc) - return f""" -Tool: {self.name} -Description: {self.description} + # Build the formatted output with title as a separate field + output = f"Tool: {self.name}\n" + + # Add human-readable title if available + if self.title: + output += f"User-readable title: {self.title}\n" + + output += f"""Description: {self.description} Arguments: {chr(10).join(args_desc)} """ + return output + class LLMClient: """Manages communication with the LLM provider.""" @@ -245,7 +238,7 @@ def get_response(self, messages: list[dict[str, str]]) -> str: } payload = { "messages": messages, - "model": "llama-3.2-90b-vision-preview", + "model": "meta-llama/llama-4-scout-17b-16e-instruct", "temperature": 0.7, "max_tokens": 4096, "top_p": 1, @@ -269,10 +262,7 @@ def get_response(self, messages: list[dict[str, str]]) -> str: logging.error(f"Status code: {status_code}") logging.error(f"Response details: {e.response.text}") - return ( - f"I encountered an error: {error_message}. " - "Please try again or rephrase your request." - ) + return f"I encountered an error: {error_message}. Please try again or rephrase your request." class ChatSession: @@ -284,12 +274,9 @@ def __init__(self, servers: list[Server], llm_client: LLMClient) -> None: async def cleanup_servers(self) -> None: """Clean up all servers properly.""" - cleanup_tasks = [ - asyncio.create_task(server.cleanup()) for server in self.servers - ] - if cleanup_tasks: + for server in reversed(self.servers): try: - await asyncio.gather(*cleanup_tasks, return_exceptions=True) + await server.cleanup() except Exception as e: logging.warning(f"Warning during final cleanup: {e}") @@ -304,8 +291,15 @@ async def process_llm_response(self, llm_response: str) -> str: """ import json + def _clean_json_string(json_string: str) -> str: + """Remove ```json ... ``` or ``` ... ``` wrappers if the LLM response is fenced.""" + import re + + pattern = r"^```(?:\s*json)?\s*(.*?)\s*```$" + return re.sub(pattern, r"\1", json_string, flags=re.DOTALL | re.IGNORECASE).strip() + try: - tool_call = json.loads(llm_response) + tool_call = json.loads(_clean_json_string(llm_response)) if "tool" in tool_call and "arguments" in tool_call: logging.info(f"Executing tool: {tool_call['tool']}") logging.info(f"With arguments: {tool_call['arguments']}") @@ -314,18 +308,13 @@ async def process_llm_response(self, llm_response: str) -> str: tools = await server.list_tools() if any(tool.name == tool_call["tool"] for tool in tools): try: - result = await server.execute_tool( - tool_call["tool"], tool_call["arguments"] - ) + result = await server.execute_tool(tool_call["tool"], tool_call["arguments"]) if isinstance(result, dict) and "progress" in result: progress = result["progress"] total = result["total"] percentage = (progress / total) * 100 - logging.info( - f"Progress: {progress}/{total} " - f"({percentage:.1f}%)" - ) + logging.info(f"Progress: {progress}/{total} ({percentage:.1f}%)") return f"Tool execution result: {result}" except Exception as e: @@ -400,9 +389,7 @@ async def start(self) -> None: final_response = self.llm_client.get_response(messages) logging.info("\nFinal response: %s", final_response) - messages.append( - {"role": "assistant", "content": final_response} - ) + messages.append({"role": "assistant", "content": final_response}) else: messages.append({"role": "assistant", "content": llm_response}) @@ -414,18 +401,19 @@ async def start(self) -> None: await self.cleanup_servers() -async def main() -> None: +async def run() -> None: """Initialize and run the chat session.""" config = Configuration() server_config = config.load_config("servers_config.json") - servers = [ - Server(name, srv_config) - for name, srv_config in server_config["mcpServers"].items() - ] + servers = [Server(name, srv_config) for name, srv_config in server_config["mcpServers"].items()] llm_client = LLMClient(config.llm_api_key) chat_session = ChatSession(servers, llm_client) await chat_session.start() +def main() -> None: + asyncio.run(run()) + + if __name__ == "__main__": - asyncio.run(main()) + main() diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt b/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt index c01e1576c2..2292072ffa 100644 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt @@ -1,4 +1,4 @@ python-dotenv>=1.0.0 requests>=2.31.0 mcp>=1.0.0 -uvicorn>=0.32.1 \ No newline at end of file +uvicorn>=0.32.1 diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json b/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json index 98f8e1fd56..3a92d05d1e 100644 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json @@ -9,4 +9,4 @@ "args": ["-y", "@modelcontextprotocol/server-puppeteer"] } } -} \ No newline at end of file +} diff --git a/examples/clients/simple-chatbot/pyproject.toml b/examples/clients/simple-chatbot/pyproject.toml index d88b8f6d2d..564b42df33 100644 --- a/examples/clients/simple-chatbot/pyproject.toml +++ b/examples/clients/simple-chatbot/pyproject.toml @@ -17,12 +17,12 @@ classifiers = [ dependencies = [ "python-dotenv>=1.0.0", "requests>=2.31.0", - "mcp>=1.0.0", - "uvicorn>=0.32.1" + "mcp", + "uvicorn>=0.32.1", ] [project.scripts] -mcp-simple-chatbot = "mcp_simple_chatbot.client:main" +mcp-simple-chatbot = "mcp_simple_chatbot.main:main" [build-system] requires = ["hatchling"] @@ -41,8 +41,8 @@ select = ["E", "F", "I"] ignore = [] [tool.ruff] -line-length = 88 +line-length = 120 target-version = "py310" -[tool.uv] -dev-dependencies = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] +[dependency-groups] +dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/clients/simple-chatbot/uv.lock b/examples/clients/simple-chatbot/uv.lock deleted file mode 100644 index ee7cb2fab7..0000000000 --- a/examples/clients/simple-chatbot/uv.lock +++ /dev/null @@ -1,555 +0,0 @@ -version = 1 -requires-python = ">=3.10" - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, -] - -[[package]] -name = "anyio" -version = "4.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, -] - -[[package]] -name = "certifi" -version = "2024.12.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 }, - { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 }, - { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 }, - { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 }, - { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 }, - { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 }, - { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 }, - { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 }, - { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 }, - { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 }, - { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 }, - { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 }, - { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 }, - { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, - { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, - { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, - { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, - { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, - { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, - { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, - { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, - { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, - { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, - { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, - { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, - { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, - { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, - { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, - { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, - { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, - { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, - { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, - { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, - { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, - { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, - { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, - { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, - { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, - { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, - { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, - { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, - { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, - { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, - { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, - { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, - { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, - { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, - { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, - { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, - { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, - { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, - { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, - { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, -] - -[[package]] -name = "click" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, -] - -[[package]] -name = "exceptiongroup" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, -] - -[[package]] -name = "h11" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, -] - -[[package]] -name = "httpcore" -version = "1.0.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, -] - -[[package]] -name = "idna" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, -] - -[[package]] -name = "iniconfig" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, -] - -[[package]] -name = "mcp" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "uvicorn" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/a5/b08dc846ebedae9f17ced878e6975826e90e448cd4592f532f6a88a925a7/mcp-1.2.0.tar.gz", hash = "sha256:2b06c7ece98d6ea9e6379caa38d74b432385c338fb530cb82e2c70ea7add94f5", size = 102973 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/84/fca78f19ac8ce6c53ba416247c71baa53a9e791e98d3c81edbc20a77d6d1/mcp-1.2.0-py3-none-any.whl", hash = "sha256:1d0e77d8c14955a5aea1f5aa1f444c8e531c09355c829b20e42f7a142bc0755f", size = 66468 }, -] - -[[package]] -name = "mcp-simple-chatbot" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "mcp" }, - { name = "python-dotenv" }, - { name = "requests" }, - { name = "uvicorn" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "mcp", specifier = ">=1.0.0" }, - { name = "python-dotenv", specifier = ">=1.0.0" }, - { name = "requests", specifier = ">=2.31.0" }, - { name = "uvicorn", specifier = ">=0.32.1" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.379" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "nodeenv" -version = "1.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, -] - -[[package]] -name = "packaging" -version = "24.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, -] - -[[package]] -name = "pluggy" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, -] - -[[package]] -name = "pydantic" -version = "2.10.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6a/c7/ca334c2ef6f2e046b1144fe4bb2a5da8a4c574e7f2ebf7e16b34a6a2fa92/pydantic-2.10.5.tar.gz", hash = "sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff", size = 761287 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/26/82663c79010b28eddf29dcdd0ea723439535fa917fce5905885c0e9ba562/pydantic-2.10.5-py3-none-any.whl", hash = "sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53", size = 431426 }, -] - -[[package]] -name = "pydantic-core" -version = "2.27.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, - { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, - { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, - { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, - { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, - { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, - { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, - { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, - { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, - { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, - { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, - { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, - { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, - { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, - { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, - { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, - { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, - { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, - { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, - { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, - { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, - { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, - { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, - { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, - { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, - { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, - { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, - { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, - { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, - { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, - { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, - { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, - { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, - { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, - { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, - { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, - { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, - { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, - { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, - { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, - { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, - { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, - { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, - { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, - { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, - { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, - { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, - { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, - { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, - { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, - { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, - { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, - { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, - { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, - { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, - { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, - { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, - { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, - { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, - { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, - { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, - { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, - { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, - { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, -] - -[[package]] -name = "pydantic-settings" -version = "2.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/73/7b/c58a586cd7d9ac66d2ee4ba60ca2d241fa837c02bca9bea80a9a8c3d22a9/pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", size = 79920 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718 }, -] - -[[package]] -name = "pyright" -version = "1.1.392.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/df/3c6f6b08fba7ccf49b114dfc4bb33e25c299883fd763f93fad47ef8bc58d/pyright-1.1.392.post0.tar.gz", hash = "sha256:3b7f88de74a28dcfa90c7d90c782b6569a48c2be5f9d4add38472bdaac247ebd", size = 3789911 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/b1/a18de17f40e4f61ca58856b9ef9b0febf74ff88978c3f7776f910071f567/pyright-1.1.392.post0-py3-none-any.whl", hash = "sha256:252f84458a46fa2f0fd4e2f91fc74f50b9ca52c757062e93f6c250c0d8329eb2", size = 5595487 }, -] - -[[package]] -name = "pytest" -version = "8.3.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, -] - -[[package]] -name = "python-dotenv" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, -] - -[[package]] -name = "requests" -version = "2.32.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, -] - -[[package]] -name = "ruff" -version = "0.9.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/63/77ecca9d21177600f551d1c58ab0e5a0b260940ea7312195bd2a4798f8a8/ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0", size = 3553799 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/b9/0e168e4e7fb3af851f739e8f07889b91d1a33a30fca8c29fa3149d6b03ec/ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347", size = 11652408 }, - { url = "https://files.pythonhosted.org/packages/2c/22/08ede5db17cf701372a461d1cb8fdde037da1d4fa622b69ac21960e6237e/ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00", size = 11587553 }, - { url = "https://files.pythonhosted.org/packages/42/05/dedfc70f0bf010230229e33dec6e7b2235b2a1b8cbb2a991c710743e343f/ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4", size = 11020755 }, - { url = "https://files.pythonhosted.org/packages/df/9b/65d87ad9b2e3def67342830bd1af98803af731243da1255537ddb8f22209/ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d", size = 11826502 }, - { url = "https://files.pythonhosted.org/packages/93/02/f2239f56786479e1a89c3da9bc9391120057fc6f4a8266a5b091314e72ce/ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c", size = 11390562 }, - { url = "https://files.pythonhosted.org/packages/c9/37/d3a854dba9931f8cb1b2a19509bfe59e00875f48ade632e95aefcb7a0aee/ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f", size = 12548968 }, - { url = "https://files.pythonhosted.org/packages/fa/c3/c7b812bb256c7a1d5553433e95980934ffa85396d332401f6b391d3c4569/ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684", size = 13187155 }, - { url = "https://files.pythonhosted.org/packages/bd/5a/3c7f9696a7875522b66aa9bba9e326e4e5894b4366bd1dc32aa6791cb1ff/ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d", size = 12704674 }, - { url = "https://files.pythonhosted.org/packages/be/d6/d908762257a96ce5912187ae9ae86792e677ca4f3dc973b71e7508ff6282/ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df", size = 14529328 }, - { url = "https://files.pythonhosted.org/packages/2d/c2/049f1e6755d12d9cd8823242fa105968f34ee4c669d04cac8cea51a50407/ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247", size = 12385955 }, - { url = "https://files.pythonhosted.org/packages/91/5a/a9bdb50e39810bd9627074e42743b00e6dc4009d42ae9f9351bc3dbc28e7/ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e", size = 11810149 }, - { url = "https://files.pythonhosted.org/packages/e5/fd/57df1a0543182f79a1236e82a79c68ce210efb00e97c30657d5bdb12b478/ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe", size = 11479141 }, - { url = "https://files.pythonhosted.org/packages/dc/16/bc3fd1d38974f6775fc152a0554f8c210ff80f2764b43777163c3c45d61b/ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb", size = 12014073 }, - { url = "https://files.pythonhosted.org/packages/47/6b/e4ca048a8f2047eb652e1e8c755f384d1b7944f69ed69066a37acd4118b0/ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a", size = 12435758 }, - { url = "https://files.pythonhosted.org/packages/c2/40/4d3d6c979c67ba24cf183d29f706051a53c36d78358036a9cd21421582ab/ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145", size = 9796916 }, - { url = "https://files.pythonhosted.org/packages/c3/ef/7f548752bdb6867e6939489c87fe4da489ab36191525fadc5cede2a6e8e2/ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5", size = 10773080 }, - { url = "https://files.pythonhosted.org/packages/0e/4e/33df635528292bd2d18404e4daabcd74ca8a9853b2e1df85ed3d32d24362/ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6", size = 10001738 }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, -] - -[[package]] -name = "sse-starlette" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "starlette" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, -] - -[[package]] -name = "starlette" -version = "0.45.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/4f/e1c9f4ec3dae67a94c9285ed275355d5f7cf0f3a5c34538c8ae5412af550/starlette-0.45.2.tar.gz", hash = "sha256:bba1831d15ae5212b22feab2f218bab6ed3cd0fc2dc1d4442443bb1ee52260e0", size = 2574026 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/ab/fe4f57c83620b39dfc9e7687ebad59129ff05170b99422105019d9a65eec/starlette-0.45.2-py3-none-any.whl", hash = "sha256:4daec3356fb0cb1e723a5235e5beaf375d2259af27532958e2d79df549dad9da", size = 71505 }, -] - -[[package]] -name = "tomli" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, -] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, -] - -[[package]] -name = "urllib3" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, -] - -[[package]] -name = "uvicorn" -version = "0.34.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, -] diff --git a/examples/clients/simple-task-client/README.md b/examples/clients/simple-task-client/README.md new file mode 100644 index 0000000000..103be0f1fb --- /dev/null +++ b/examples/clients/simple-task-client/README.md @@ -0,0 +1,43 @@ +# Simple Task Client + +A minimal MCP client demonstrating polling for task results over streamable HTTP. + +## Running + +First, start the simple-task server in another terminal: + +```bash +cd examples/servers/simple-task +uv run mcp-simple-task +``` + +Then run the client: + +```bash +cd examples/clients/simple-task-client +uv run mcp-simple-task-client +``` + +Use `--url` to connect to a different server. + +## What it does + +1. Connects to the server via streamable HTTP +2. Calls the `long_running_task` tool as a task +3. Polls the task status until completion +4. Retrieves and prints the result + +## Expected output + +```text +Available tools: ['long_running_task'] + +Calling tool as a task... +Task created: + Status: working - Starting work... + Status: working - Processing step 1... + Status: working - Processing step 2... + Status: completed - + +Result: Task completed! +``` diff --git a/examples/clients/simple-task-client/mcp_simple_task_client/__init__.py b/examples/clients/simple-task-client/mcp_simple_task_client/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/clients/simple-task-client/mcp_simple_task_client/__main__.py b/examples/clients/simple-task-client/mcp_simple_task_client/__main__.py new file mode 100644 index 0000000000..2fc2cda8d9 --- /dev/null +++ b/examples/clients/simple-task-client/mcp_simple_task_client/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .main import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/clients/simple-task-client/mcp_simple_task_client/main.py b/examples/clients/simple-task-client/mcp_simple_task_client/main.py new file mode 100644 index 0000000000..12691162ab --- /dev/null +++ b/examples/clients/simple-task-client/mcp_simple_task_client/main.py @@ -0,0 +1,55 @@ +"""Simple task client demonstrating MCP tasks polling over streamable HTTP.""" + +import asyncio + +import click +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client +from mcp.types import CallToolResult, TextContent + + +async def run(url: str) -> None: + async with streamablehttp_client(url) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + + # List tools + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}") + + # Call the tool as a task + print("\nCalling tool as a task...") + + result = await session.experimental.call_tool_as_task( + "long_running_task", + arguments={}, + ttl=60000, + ) + task_id = result.task.taskId + print(f"Task created: {task_id}") + + # Poll until done (respects server's pollInterval hint) + async for status in session.experimental.poll_task(task_id): + print(f" Status: {status.status} - {status.statusMessage or ''}") + + # Check final status + if status.status != "completed": + print(f"Task ended with status: {status.status}") + return + + # Get the result + task_result = await session.experimental.get_task_result(task_id, CallToolResult) + content = task_result.content[0] + if isinstance(content, TextContent): + print(f"\nResult: {content.text}") + + +@click.command() +@click.option("--url", default="http://localhost:8000/mcp", help="Server URL") +def main(url: str) -> int: + asyncio.run(run(url)) + return 0 + + +if __name__ == "__main__": + main() diff --git a/examples/clients/simple-task-client/pyproject.toml b/examples/clients/simple-task-client/pyproject.toml new file mode 100644 index 0000000000..da10392e3c --- /dev/null +++ b/examples/clients/simple-task-client/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-task-client" +version = "0.1.0" +description = "A simple MCP client demonstrating task polling" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic, PBC." }] +keywords = ["mcp", "llm", "tasks", "client"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["click>=8.0", "mcp"] + +[project.scripts] +mcp-simple-task-client = "mcp_simple_task_client.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_task_client"] + +[tool.pyright] +include = ["mcp_simple_task_client"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "ruff>=0.6.9"] diff --git a/examples/clients/simple-task-interactive-client/README.md b/examples/clients/simple-task-interactive-client/README.md new file mode 100644 index 0000000000..ac73d2bc12 --- /dev/null +++ b/examples/clients/simple-task-interactive-client/README.md @@ -0,0 +1,87 @@ +# Simple Interactive Task Client + +A minimal MCP client demonstrating responses to interactive tasks (elicitation and sampling). + +## Running + +First, start the interactive task server in another terminal: + +```bash +cd examples/servers/simple-task-interactive +uv run mcp-simple-task-interactive +``` + +Then run the client: + +```bash +cd examples/clients/simple-task-interactive-client +uv run mcp-simple-task-interactive-client +``` + +Use `--url` to connect to a different server. + +## What it does + +1. Connects to the server via streamable HTTP +2. Calls `confirm_delete` - server asks for confirmation, client responds via terminal +3. Calls `write_haiku` - server requests LLM completion, client returns a hardcoded haiku + +## Key concepts + +### Elicitation callback + +```python +async def elicitation_callback(context, params) -> ElicitResult: + # Handle user input request from server + return ElicitResult(action="accept", content={"confirm": True}) +``` + +### Sampling callback + +```python +async def sampling_callback(context, params) -> CreateMessageResult: + # Handle LLM completion request from server + return CreateMessageResult(model="...", role="assistant", content=...) +``` + +### Using call_tool_as_task + +```python +# Call a tool as a task (returns immediately with task reference) +result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) +task_id = result.task.taskId + +# Get result - this delivers elicitation/sampling requests and blocks until complete +final = await session.experimental.get_task_result(task_id, CallToolResult) +``` + +**Important**: The `get_task_result()` call is what triggers the delivery of elicitation +and sampling requests to your callbacks. It blocks until the task completes and returns +the final result. + +## Expected output + +```text +Available tools: ['confirm_delete', 'write_haiku'] + +--- Demo 1: Elicitation --- +Calling confirm_delete tool... +Task created: + +[Elicitation] Server asks: Are you sure you want to delete 'important.txt'? +Your response (y/n): y +[Elicitation] Responding with: confirm=True +Result: Deleted 'important.txt' + +--- Demo 2: Sampling --- +Calling write_haiku tool... +Task created: + +[Sampling] Server requests LLM completion for: Write a haiku about autumn leaves +[Sampling] Responding with haiku +Result: +Haiku: +Cherry blossoms fall +Softly on the quiet pond +Spring whispers goodbye +``` diff --git a/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/__init__.py b/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/__main__.py b/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/__main__.py new file mode 100644 index 0000000000..2fc2cda8d9 --- /dev/null +++ b/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .main import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/main.py b/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/main.py new file mode 100644 index 0000000000..a8a47dc57c --- /dev/null +++ b/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/main.py @@ -0,0 +1,138 @@ +"""Simple interactive task client demonstrating elicitation and sampling responses. + +This example demonstrates the spec-compliant polling pattern: +1. Poll tasks/get watching for status changes +2. On input_required, call tasks/result to receive elicitation/sampling requests +3. Continue until terminal status, then retrieve final result +""" + +import asyncio +from typing import Any + +import click +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client +from mcp.shared.context import RequestContext +from mcp.types import ( + CallToolResult, + CreateMessageRequestParams, + CreateMessageResult, + ElicitRequestParams, + ElicitResult, + TextContent, +) + + +async def elicitation_callback( + context: RequestContext[ClientSession, Any], + params: ElicitRequestParams, +) -> ElicitResult: + """Handle elicitation requests from the server.""" + print(f"\n[Elicitation] Server asks: {params.message}") + + # Simple terminal prompt + response = input("Your response (y/n): ").strip().lower() + confirmed = response in ("y", "yes", "true", "1") + + print(f"[Elicitation] Responding with: confirm={confirmed}") + return ElicitResult(action="accept", content={"confirm": confirmed}) + + +async def sampling_callback( + context: RequestContext[ClientSession, Any], + params: CreateMessageRequestParams, +) -> CreateMessageResult: + """Handle sampling requests from the server.""" + # Get the prompt from the first message + prompt = "unknown" + if params.messages: + content = params.messages[0].content + if isinstance(content, TextContent): + prompt = content.text + + print(f"\n[Sampling] Server requests LLM completion for: {prompt}") + + # Return a hardcoded haiku (in real use, call your LLM here) + haiku = """Cherry blossoms fall +Softly on the quiet pond +Spring whispers goodbye""" + + print("[Sampling] Responding with haiku") + return CreateMessageResult( + model="mock-haiku-model", + role="assistant", + content=TextContent(type="text", text=haiku), + ) + + +def get_text(result: CallToolResult) -> str: + """Extract text from a CallToolResult.""" + if result.content and isinstance(result.content[0], TextContent): + return result.content[0].text + return "(no text)" + + +async def run(url: str) -> None: + async with streamablehttp_client(url) as (read, write, _): + async with ClientSession( + read, + write, + elicitation_callback=elicitation_callback, + sampling_callback=sampling_callback, + ) as session: + await session.initialize() + + # List tools + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}") + + # Demo 1: Elicitation (confirm_delete) + print("\n--- Demo 1: Elicitation ---") + print("Calling confirm_delete tool...") + + elicit_task = await session.experimental.call_tool_as_task("confirm_delete", {"filename": "important.txt"}) + elicit_task_id = elicit_task.task.taskId + print(f"Task created: {elicit_task_id}") + + # Poll until terminal, calling tasks/result on input_required + async for status in session.experimental.poll_task(elicit_task_id): + print(f"[Poll] Status: {status.status}") + if status.status == "input_required": + # Server needs input - tasks/result delivers the elicitation request + elicit_result = await session.experimental.get_task_result(elicit_task_id, CallToolResult) + break + else: + # poll_task exited due to terminal status + elicit_result = await session.experimental.get_task_result(elicit_task_id, CallToolResult) + + print(f"Result: {get_text(elicit_result)}") + + # Demo 2: Sampling (write_haiku) + print("\n--- Demo 2: Sampling ---") + print("Calling write_haiku tool...") + + sampling_task = await session.experimental.call_tool_as_task("write_haiku", {"topic": "autumn leaves"}) + sampling_task_id = sampling_task.task.taskId + print(f"Task created: {sampling_task_id}") + + # Poll until terminal, calling tasks/result on input_required + async for status in session.experimental.poll_task(sampling_task_id): + print(f"[Poll] Status: {status.status}") + if status.status == "input_required": + sampling_result = await session.experimental.get_task_result(sampling_task_id, CallToolResult) + break + else: + sampling_result = await session.experimental.get_task_result(sampling_task_id, CallToolResult) + + print(f"Result:\n{get_text(sampling_result)}") + + +@click.command() +@click.option("--url", default="http://localhost:8000/mcp", help="Server URL") +def main(url: str) -> int: + asyncio.run(run(url)) + return 0 + + +if __name__ == "__main__": + main() diff --git a/examples/clients/simple-task-interactive-client/pyproject.toml b/examples/clients/simple-task-interactive-client/pyproject.toml new file mode 100644 index 0000000000..224bbc5917 --- /dev/null +++ b/examples/clients/simple-task-interactive-client/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-task-interactive-client" +version = "0.1.0" +description = "A simple MCP client demonstrating interactive task responses" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic, PBC." }] +keywords = ["mcp", "llm", "tasks", "client", "elicitation", "sampling"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["click>=8.0", "mcp"] + +[project.scripts] +mcp-simple-task-interactive-client = "mcp_simple_task_interactive_client.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_task_interactive_client"] + +[tool.pyright] +include = ["mcp_simple_task_interactive_client"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "ruff>=0.6.9"] diff --git a/examples/clients/sse-polling-client/README.md b/examples/clients/sse-polling-client/README.md new file mode 100644 index 0000000000..78449aa832 --- /dev/null +++ b/examples/clients/sse-polling-client/README.md @@ -0,0 +1,30 @@ +# MCP SSE Polling Demo Client + +Demonstrates client-side auto-reconnect for the SSE polling pattern (SEP-1699). + +## Features + +- Connects to SSE polling demo server +- Automatically reconnects when server closes SSE stream +- Resumes from Last-Event-ID to avoid missing messages +- Respects server-provided retry interval + +## Usage + +```bash +# First start the server: +uv run mcp-sse-polling-demo --port 3000 + +# Then run this client: +uv run mcp-sse-polling-client --url http://localhost:3000/mcp + +# Custom options: +uv run mcp-sse-polling-client --url http://localhost:3000/mcp --items 20 --checkpoint-every 5 +``` + +## Options + +- `--url`: Server URL (default: ) +- `--items`: Number of items to process (default: 10) +- `--checkpoint-every`: Checkpoint interval (default: 3) +- `--log-level`: Logging level (default: DEBUG) diff --git a/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py b/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py new file mode 100644 index 0000000000..ee69b32c96 --- /dev/null +++ b/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py @@ -0,0 +1 @@ +"""SSE Polling Demo Client - demonstrates auto-reconnect for long-running tasks.""" diff --git a/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py b/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py new file mode 100644 index 0000000000..1defd8eaa4 --- /dev/null +++ b/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py @@ -0,0 +1,105 @@ +""" +SSE Polling Demo Client + +Demonstrates the client-side auto-reconnect for SSE polling pattern. + +This client connects to the SSE Polling Demo server and calls process_batch, +which triggers periodic server-side stream closes. The client automatically +reconnects using Last-Event-ID and resumes receiving messages. + +Run with: + # First start the server: + uv run mcp-sse-polling-demo --port 3000 + + # Then run this client: + uv run mcp-sse-polling-client --url http://localhost:3000/mcp +""" + +import asyncio +import logging + +import click +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +logger = logging.getLogger(__name__) + + +async def run_demo(url: str, items: int, checkpoint_every: int) -> None: + """Run the SSE polling demo.""" + print(f"\n{'=' * 60}") + print("SSE Polling Demo Client") + print(f"{'=' * 60}") + print(f"Server URL: {url}") + print(f"Processing {items} items with checkpoints every {checkpoint_every}") + print(f"{'=' * 60}\n") + + async with streamablehttp_client(url) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the connection + print("Initializing connection...") + await session.initialize() + print("Connected!\n") + + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}\n") + + # Call the process_batch tool + print(f"Calling process_batch(items={items}, checkpoint_every={checkpoint_every})...\n") + print("-" * 40) + + result = await session.call_tool( + "process_batch", + { + "items": items, + "checkpoint_every": checkpoint_every, + }, + ) + + print("-" * 40) + if result.content: + content = result.content[0] + text = getattr(content, "text", str(content)) + print(f"\nResult: {text}") + else: + print("\nResult: No content") + print(f"{'=' * 60}\n") + + +@click.command() +@click.option( + "--url", + default="http://localhost:3000/mcp", + help="Server URL", +) +@click.option( + "--items", + default=10, + help="Number of items to process", +) +@click.option( + "--checkpoint-every", + default=3, + help="Checkpoint interval", +) +@click.option( + "--log-level", + default="INFO", + help="Logging level", +) +def main(url: str, items: int, checkpoint_every: int, log_level: str) -> None: + """Run the SSE Polling Demo client.""" + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + # Suppress noisy HTTP client logging + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + + asyncio.run(run_demo(url, items, checkpoint_every)) + + +if __name__ == "__main__": + main() diff --git a/examples/clients/sse-polling-client/pyproject.toml b/examples/clients/sse-polling-client/pyproject.toml new file mode 100644 index 0000000000..ae896708d4 --- /dev/null +++ b/examples/clients/sse-polling-client/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "mcp-sse-polling-client" +version = "0.1.0" +description = "Demo client for SSE polling with auto-reconnect" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic, PBC." }] +keywords = ["mcp", "sse", "polling", "client"] +license = { text = "MIT" } +dependencies = ["click>=8.2.0", "mcp"] + +[project.scripts] +mcp-sse-polling-client = "mcp_sse_polling_client.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_sse_polling_client"] + +[tool.pyright] +include = ["mcp_sse_polling_client"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/fastmcp/desktop.py b/examples/fastmcp/desktop.py index 8fd71b263b..add7f515bc 100644 --- a/examples/fastmcp/desktop.py +++ b/examples/fastmcp/desktop.py @@ -20,6 +20,6 @@ def desktop() -> list[str]: @mcp.tool() -def add(a: int, b: int) -> int: +def sum(a: int, b: int) -> int: """Add two numbers""" return a + b diff --git a/examples/fastmcp/direct_call_tool_result_return.py b/examples/fastmcp/direct_call_tool_result_return.py new file mode 100644 index 0000000000..a441769b2a --- /dev/null +++ b/examples/fastmcp/direct_call_tool_result_return.py @@ -0,0 +1,24 @@ +""" +FastMCP Echo Server with direct CallToolResult return +""" + +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server.fastmcp import FastMCP +from mcp.types import CallToolResult, TextContent + +mcp = FastMCP("Echo Server") + + +class EchoResponse(BaseModel): + text: str + + +@mcp.tool() +def echo(text: str) -> Annotated[CallToolResult, EchoResponse]: + """Echo the input text with structure and metadata""" + return CallToolResult( + content=[TextContent(type="text", text=text)], structuredContent={"text": text}, _meta={"some": "metadata"} + ) diff --git a/examples/fastmcp/icons_demo.py b/examples/fastmcp/icons_demo.py new file mode 100644 index 0000000000..c6cf48acd8 --- /dev/null +++ b/examples/fastmcp/icons_demo.py @@ -0,0 +1,55 @@ +""" +FastMCP Icons Demo Server + +Demonstrates using icons with tools, resources, prompts, and implementation. +""" + +import base64 +from pathlib import Path + +from mcp.server.fastmcp import FastMCP, Icon + +# Load the icon file and convert to data URI +icon_path = Path(__file__).parent / "mcp.png" +icon_data = base64.standard_b64encode(icon_path.read_bytes()).decode() +icon_data_uri = f"data:image/png;base64,{icon_data}" + +icon_data = Icon(src=icon_data_uri, mimeType="image/png", sizes=["64x64"]) + +# Create server with icons in implementation +mcp = FastMCP("Icons Demo Server", website_url="https://github.com/modelcontextprotocol/python-sdk", icons=[icon_data]) + + +@mcp.tool(icons=[icon_data]) +def demo_tool(message: str) -> str: + """A demo tool with an icon.""" + return message + + +@mcp.resource("demo://readme", icons=[icon_data]) +def readme_resource() -> str: + """A demo resource with an icon""" + return "This resource has an icon" + + +@mcp.prompt("prompt_with_icon", icons=[icon_data]) +def prompt_with_icon(text: str) -> str: + """A demo prompt with an icon""" + return text + + +@mcp.tool( + icons=[ + Icon(src=icon_data_uri, mimeType="image/png", sizes=["16x16"]), + Icon(src=icon_data_uri, mimeType="image/png", sizes=["32x32"]), + Icon(src=icon_data_uri, mimeType="image/png", sizes=["64x64"]), + ] +) +def multi_icon_tool(action: str) -> str: + """A tool demonstrating multiple icons.""" + return "multi_icon_tool" + + +if __name__ == "__main__": + # Run the server + mcp.run() diff --git a/examples/fastmcp/logging_and_progress.py b/examples/fastmcp/logging_and_progress.py new file mode 100644 index 0000000000..91c2b806dd --- /dev/null +++ b/examples/fastmcp/logging_and_progress.py @@ -0,0 +1,33 @@ +""" +FastMCP Echo Server that sends log messages and progress updates to the client +""" + +import asyncio + +from mcp.server.fastmcp import Context, FastMCP + +# Create server +mcp = FastMCP("Echo Server with logging and progress updates") + + +@mcp.tool() +async def echo(text: str, ctx: Context) -> str: + """Echo the input text sending log messages and progress updates during processing.""" + await ctx.report_progress(progress=0, total=100) + await ctx.info("Starting to process echo for input: " + text) + + await asyncio.sleep(2) + + await ctx.info("Halfway through processing echo for input: " + text) + await ctx.report_progress(progress=50, total=100) + + await asyncio.sleep(2) + + await ctx.info("Finished processing echo for input: " + text) + await ctx.report_progress(progress=100, total=100) + + # Progress notifications are process asynchronously by the client. + # A small delay here helps ensure the last notification is processed by the client. + await asyncio.sleep(0.1) + + return text diff --git a/examples/fastmcp/mcp.png b/examples/fastmcp/mcp.png new file mode 100644 index 0000000000..8e08571d32 Binary files /dev/null and b/examples/fastmcp/mcp.png differ diff --git a/examples/fastmcp/memory.py b/examples/fastmcp/memory.py index dbc8908150..35094ec9c8 100644 --- a/examples/fastmcp/memory.py +++ b/examples/fastmcp/memory.py @@ -16,7 +16,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Annotated, Self +from typing import Annotated, Self, TypeVar import asyncpg import numpy as np @@ -35,6 +35,8 @@ DEFAULT_LLM_MODEL = "openai:gpt-4o" DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small" +T = TypeVar("T") + mcp = FastMCP( "memory", dependencies=[ @@ -47,21 +49,17 @@ DB_DSN = "postgresql://postgres:postgres@localhost:54320/memory_db" # reset memory with rm ~/.fastmcp/{USER}/memory/* -PROFILE_DIR = ( - Path.home() / ".fastmcp" / os.environ.get("USER", "anon") / "memory" -).resolve() +PROFILE_DIR = (Path.home() / ".fastmcp" / os.environ.get("USER", "anon") / "memory").resolve() PROFILE_DIR.mkdir(parents=True, exist_ok=True) def cosine_similarity(a: list[float], b: list[float]) -> float: a_array = np.array(a, dtype=np.float64) b_array = np.array(b, dtype=np.float64) - return np.dot(a_array, b_array) / ( - np.linalg.norm(a_array) * np.linalg.norm(b_array) - ) + return np.dot(a_array, b_array) / (np.linalg.norm(a_array) * np.linalg.norm(b_array)) -async def do_ai[T]( +async def do_ai( user_prompt: str, system_prompt: str, result_type: type[T] | Annotated, @@ -97,9 +95,7 @@ class MemoryNode(BaseModel): summary: str = "" importance: float = 1.0 access_count: int = 0 - timestamp: float = Field( - default_factory=lambda: datetime.now(timezone.utc).timestamp() - ) + timestamp: float = Field(default_factory=lambda: datetime.now(timezone.utc).timestamp()) embedding: list[float] @classmethod @@ -152,9 +148,7 @@ async def merge_with(self, other: Self, deps: Deps): self.importance += other.importance self.access_count += other.access_count self.embedding = [(a + b) / 2 for a, b in zip(self.embedding, other.embedding)] - self.summary = await do_ai( - self.content, "Summarize the following text concisely.", str, deps - ) + self.summary = await do_ai(self.content, "Summarize the following text concisely.", str, deps) await self.save(deps) # Delete the merged node from the database if other.id is not None: @@ -221,9 +215,7 @@ async def find_similar_memories(embedding: list[float], deps: Deps) -> list[Memo async def update_importance(user_embedding: list[float], deps: Deps): async with deps.pool.acquire() as conn: - rows = await conn.fetch( - "SELECT id, importance, access_count, embedding FROM memories" - ) + rows = await conn.fetch("SELECT id, importance, access_count, embedding FROM memories") for row in rows: memory_embedding = row["embedding"] similarity = cosine_similarity(user_embedding, memory_embedding) @@ -273,9 +265,7 @@ async def display_memory_tree(deps: Deps) -> str: ) result = "" for row in rows: - effective_importance = row["importance"] * ( - 1 + math.log(row["access_count"] + 1) - ) + effective_importance = row["importance"] * (1 + math.log(row["access_count"] + 1)) summary = row["summary"] or row["content"] result += f"- {summary} (Importance: {effective_importance:.2f})\n" return result @@ -283,15 +273,11 @@ async def display_memory_tree(deps: Deps) -> str: @mcp.tool() async def remember( - contents: list[str] = Field( - description="List of observations or memories to store" - ), + contents: list[str] = Field(description="List of observations or memories to store"), ): deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool()) try: - return "\n".join( - await asyncio.gather(*[add_memory(content, deps) for content in contents]) - ) + return "\n".join(await asyncio.gather(*[add_memory(content, deps) for content in contents])) finally: await deps.pool.close() @@ -305,9 +291,7 @@ async def read_profile() -> str: async def initialize_database(): - pool = await asyncpg.create_pool( - "postgresql://postgres:postgres@localhost:54320/postgres" - ) + pool = await asyncpg.create_pool("postgresql://postgres:postgres@localhost:54320/postgres") try: async with pool.acquire() as conn: await conn.execute(""" diff --git a/examples/fastmcp/readme-quickstart.py b/examples/fastmcp/readme-quickstart.py index d1c522a816..e1abf7c518 100644 --- a/examples/fastmcp/readme-quickstart.py +++ b/examples/fastmcp/readme-quickstart.py @@ -6,7 +6,7 @@ # Add an addition tool @mcp.tool() -def add(a: int, b: int) -> int: +def sum(a: int, b: int) -> int: """Add two numbers""" return a + b diff --git a/examples/fastmcp/text_me.py b/examples/fastmcp/text_me.py index 8053c6cc5e..2434dcddd9 100644 --- a/examples/fastmcp/text_me.py +++ b/examples/fastmcp/text_me.py @@ -28,15 +28,11 @@ class SurgeSettings(BaseSettings): - model_config: SettingsConfigDict = SettingsConfigDict( - env_prefix="SURGE_", env_file=".env" - ) + model_config: SettingsConfigDict = SettingsConfigDict(env_prefix="SURGE_", env_file=".env") api_key: str account_id: str - my_phone_number: Annotated[ - str, BeforeValidator(lambda v: "+" + v if not v.startswith("+") else v) - ] + my_phone_number: Annotated[str, BeforeValidator(lambda v: "+" + v if not v.startswith("+") else v)] my_first_name: str my_last_name: str diff --git a/examples/fastmcp/unicode_example.py b/examples/fastmcp/unicode_example.py index a69f586a53..bb487f6180 100644 --- a/examples/fastmcp/unicode_example.py +++ b/examples/fastmcp/unicode_example.py @@ -8,10 +8,7 @@ mcp = FastMCP() -@mcp.tool( - description="🌟 A tool that uses various Unicode characters in its description: " - "á é í ó ú ñ 漢字 🎉" -) +@mcp.tool(description="🌟 A tool that uses various Unicode characters in its description: á é í ó ú ñ 漢字 🎉") def hello_unicode(name: str = "世界", greeting: str = "¡Hola") -> str: """ A simple tool that demonstrates Unicode handling in: diff --git a/examples/fastmcp/weather_structured.py b/examples/fastmcp/weather_structured.py new file mode 100644 index 0000000000..20cbf79578 --- /dev/null +++ b/examples/fastmcp/weather_structured.py @@ -0,0 +1,225 @@ +""" +FastMCP Weather Example with Structured Output + +Demonstrates how to use structured output with tools to return +well-typed, validated data that clients can easily process. +""" + +import asyncio +import json +import sys +from dataclasses import dataclass +from datetime import datetime +from typing import TypedDict + +from pydantic import BaseModel, Field + +from mcp.server.fastmcp import FastMCP +from mcp.shared.memory import create_connected_server_and_client_session as client_session + +# Create server +mcp = FastMCP("Weather Service") + + +# Example 1: Using a Pydantic model for structured output +class WeatherData(BaseModel): + """Structured weather data response""" + + temperature: float = Field(description="Temperature in Celsius") + humidity: float = Field(description="Humidity percentage (0-100)") + condition: str = Field(description="Weather condition (sunny, cloudy, rainy, etc.)") + wind_speed: float = Field(description="Wind speed in km/h") + location: str = Field(description="Location name") + timestamp: datetime = Field(default_factory=datetime.now, description="Observation time") + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Get current weather for a city with full structured data""" + # In a real implementation, this would fetch from a weather API + return WeatherData(temperature=22.5, humidity=65.0, condition="partly cloudy", wind_speed=12.3, location=city) + + +# Example 2: Using TypedDict for a simpler structure +class WeatherSummary(TypedDict): + """Simple weather summary""" + + city: str + temp_c: float + description: str + + +@mcp.tool() +def get_weather_summary(city: str) -> WeatherSummary: + """Get a brief weather summary for a city""" + return WeatherSummary(city=city, temp_c=22.5, description="Partly cloudy with light breeze") + + +# Example 3: Using dict[str, Any] for flexible schemas +@mcp.tool() +def get_weather_metrics(cities: list[str]) -> dict[str, dict[str, float]]: + """Get weather metrics for multiple cities + + Returns a dictionary mapping city names to their metrics + """ + # Returns nested dictionaries with weather metrics + return { + city: {"temperature": 20.0 + i * 2, "humidity": 60.0 + i * 5, "pressure": 1013.0 + i * 0.5} + for i, city in enumerate(cities) + } + + +# Example 4: Using dataclass for weather alerts +@dataclass +class WeatherAlert: + """Weather alert information""" + + severity: str # "low", "medium", "high" + title: str + description: str + affected_areas: list[str] + valid_until: datetime + + +@mcp.tool() +def get_weather_alerts(region: str) -> list[WeatherAlert]: + """Get active weather alerts for a region""" + # In production, this would fetch real alerts + if region.lower() == "california": + return [ + WeatherAlert( + severity="high", + title="Heat Wave Warning", + description="Temperatures expected to exceed 40 degrees", + affected_areas=["Los Angeles", "San Diego", "Riverside"], + valid_until=datetime(2024, 7, 15, 18, 0), + ), + WeatherAlert( + severity="medium", + title="Air Quality Advisory", + description="Poor air quality due to wildfire smoke", + affected_areas=["San Francisco Bay Area"], + valid_until=datetime(2024, 7, 14, 12, 0), + ), + ] + return [] + + +# Example 5: Returning primitives with structured output +@mcp.tool() +def get_temperature(city: str, unit: str = "celsius") -> float: + """Get just the temperature for a city + + When returning primitives as structured output, + the result is wrapped in {"result": value} + """ + base_temp = 22.5 + if unit.lower() == "fahrenheit": + return base_temp * 9 / 5 + 32 + return base_temp + + +# Example 6: Weather statistics with nested models +class DailyStats(BaseModel): + """Statistics for a single day""" + + high: float + low: float + mean: float + + +class WeatherStats(BaseModel): + """Weather statistics over a period""" + + location: str + period_days: int + temperature: DailyStats + humidity: DailyStats + precipitation_mm: float = Field(description="Total precipitation in millimeters") + + +@mcp.tool() +def get_weather_stats(city: str, days: int = 7) -> WeatherStats: + """Get weather statistics for the past N days""" + return WeatherStats( + location=city, + period_days=days, + temperature=DailyStats(high=28.5, low=15.2, mean=21.8), + humidity=DailyStats(high=85.0, low=45.0, mean=65.0), + precipitation_mm=12.4, + ) + + +if __name__ == "__main__": + + async def test() -> None: + """Test the tools by calling them through the server as a client would""" + print("Testing Weather Service Tools (via MCP protocol)\n") + print("=" * 80) + + async with client_session(mcp._mcp_server) as client: + # Test get_weather + result = await client.call_tool("get_weather", {"city": "London"}) + print("\nWeather in London:") + print(json.dumps(result.structuredContent, indent=2)) + + # Test get_weather_summary + result = await client.call_tool("get_weather_summary", {"city": "Paris"}) + print("\nWeather summary for Paris:") + print(json.dumps(result.structuredContent, indent=2)) + + # Test get_weather_metrics + result = await client.call_tool("get_weather_metrics", {"cities": ["Tokyo", "Sydney", "Mumbai"]}) + print("\nWeather metrics:") + print(json.dumps(result.structuredContent, indent=2)) + + # Test get_weather_alerts + result = await client.call_tool("get_weather_alerts", {"region": "California"}) + print("\nWeather alerts for California:") + print(json.dumps(result.structuredContent, indent=2)) + + # Test get_temperature + result = await client.call_tool("get_temperature", {"city": "Berlin", "unit": "fahrenheit"}) + print("\nTemperature in Berlin:") + print(json.dumps(result.structuredContent, indent=2)) + + # Test get_weather_stats + result = await client.call_tool("get_weather_stats", {"city": "Seattle", "days": 30}) + print("\nWeather stats for Seattle (30 days):") + print(json.dumps(result.structuredContent, indent=2)) + + # Also show the text content for comparison + print("\nText content for last result:") + for content in result.content: + if content.type == "text": + print(content.text) + + async def print_schemas() -> None: + """Print all tool schemas""" + print("Tool Schemas for Weather Service\n") + print("=" * 80) + + tools = await mcp.list_tools() + for tool in tools: + print(f"\nTool: {tool.name}") + print(f"Description: {tool.description}") + print("Input Schema:") + print(json.dumps(tool.inputSchema, indent=2)) + + if tool.outputSchema: + print("Output Schema:") + print(json.dumps(tool.outputSchema, indent=2)) + else: + print("Output Schema: None (returns unstructured content)") + + print("-" * 80) + + # Check command line arguments + if len(sys.argv) > 1 and sys.argv[1] == "--schemas": + asyncio.run(print_schemas()) + else: + print("Usage:") + print(" python weather_structured.py # Run tool tests") + print(" python weather_structured.py --schemas # Print tool schemas") + print() + asyncio.run(test()) diff --git a/examples/servers/everything-server/README.md b/examples/servers/everything-server/README.md new file mode 100644 index 0000000000..3512665cb9 --- /dev/null +++ b/examples/servers/everything-server/README.md @@ -0,0 +1,42 @@ +# MCP Everything Server + +A comprehensive MCP server implementing all protocol features for conformance testing. + +## Overview + +The Everything Server is a reference implementation that demonstrates all features of the Model Context Protocol (MCP). It is designed to be used with the [MCP Conformance Test Framework](https://github.com/modelcontextprotocol/conformance) to validate MCP client and server implementations. + +## Installation + +From the python-sdk root directory: + +```bash +uv sync --frozen +``` + +## Usage + +### Running the Server + +Start the server with default settings (port 3001): + +```bash +uv run -m mcp_everything_server +``` + +Or with custom options: + +```bash +uv run -m mcp_everything_server --port 3001 --log-level DEBUG +``` + +The server will be available at: `http://localhost:3001/mcp` + +### Command-Line Options + +- `--port` - Port to listen on (default: 3001) +- `--log-level` - Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO) + +## Running Conformance Tests + +See the [MCP Conformance Test Framework](https://github.com/modelcontextprotocol/conformance) for instructions on running conformance tests against this server. diff --git a/examples/servers/everything-server/mcp_everything_server/__init__.py b/examples/servers/everything-server/mcp_everything_server/__init__.py new file mode 100644 index 0000000000..d539062d4f --- /dev/null +++ b/examples/servers/everything-server/mcp_everything_server/__init__.py @@ -0,0 +1,3 @@ +"""MCP Everything Server - Comprehensive conformance test server.""" + +__version__ = "0.1.0" diff --git a/examples/servers/everything-server/mcp_everything_server/__main__.py b/examples/servers/everything-server/mcp_everything_server/__main__.py new file mode 100644 index 0000000000..2eff688f02 --- /dev/null +++ b/examples/servers/everything-server/mcp_everything_server/__main__.py @@ -0,0 +1,6 @@ +"""CLI entry point for the MCP Everything Server.""" + +from .server import main + +if __name__ == "__main__": + main() diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py new file mode 100644 index 0000000000..1f1ee7ecc4 --- /dev/null +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -0,0 +1,458 @@ +#!/usr/bin/env python3 +""" +MCP Everything Server - Conformance Test Server + +Server implementing all MCP features for conformance testing based on Conformance Server Specification. +""" + +import asyncio +import base64 +import json +import logging + +import click +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.fastmcp.prompts.base import UserMessage +from mcp.server.session import ServerSession +from mcp.server.streamable_http import EventCallback, EventMessage, EventStore +from mcp.types import ( + AudioContent, + Completion, + CompletionArgument, + CompletionContext, + EmbeddedResource, + ImageContent, + JSONRPCMessage, + PromptReference, + ResourceTemplateReference, + SamplingMessage, + TextContent, + TextResourceContents, +) +from pydantic import AnyUrl, BaseModel, Field + +logger = logging.getLogger(__name__) + +# Type aliases for event store +StreamId = str +EventId = str + + +class InMemoryEventStore(EventStore): + """Simple in-memory event store for SSE resumability testing.""" + + def __init__(self) -> None: + self._events: list[tuple[StreamId, EventId, JSONRPCMessage | None]] = [] + self._event_id_counter = 0 + + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + """Store an event and return its ID.""" + self._event_id_counter += 1 + event_id = str(self._event_id_counter) + self._events.append((stream_id, event_id, message)) + return event_id + + async def replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None: + """Replay events after the specified ID.""" + target_stream_id = None + for stream_id, event_id, _ in self._events: + if event_id == last_event_id: + target_stream_id = stream_id + break + if target_stream_id is None: + return None + last_event_id_int = int(last_event_id) + for stream_id, event_id, message in self._events: + if stream_id == target_stream_id and int(event_id) > last_event_id_int: + # Skip priming events (None message) + if message is not None: + await send_callback(EventMessage(message, event_id)) + return target_stream_id + + +# Test data +TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" +TEST_AUDIO_BASE64 = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=" + +# Server state +resource_subscriptions: set[str] = set() +watched_resource_content = "Watched resource content" + +# Create event store for SSE resumability (SEP-1699) +event_store = InMemoryEventStore() + +mcp = FastMCP( + name="mcp-conformance-test-server", + event_store=event_store, + retry_interval=100, # 100ms retry interval for SSE polling +) + + +# Tools +@mcp.tool() +def test_simple_text() -> str: + """Tests simple text content response""" + return "This is a simple text response for testing." + + +@mcp.tool() +def test_image_content() -> list[ImageContent]: + """Tests image content response""" + return [ImageContent(type="image", data=TEST_IMAGE_BASE64, mimeType="image/png")] + + +@mcp.tool() +def test_audio_content() -> list[AudioContent]: + """Tests audio content response""" + return [AudioContent(type="audio", data=TEST_AUDIO_BASE64, mimeType="audio/wav")] + + +@mcp.tool() +def test_embedded_resource() -> list[EmbeddedResource]: + """Tests embedded resource content response""" + return [ + EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=AnyUrl("test://embedded-resource"), + mimeType="text/plain", + text="This is an embedded resource content.", + ), + ) + ] + + +@mcp.tool() +def test_multiple_content_types() -> list[TextContent | ImageContent | EmbeddedResource]: + """Tests response with multiple content types (text, image, resource)""" + return [ + TextContent(type="text", text="Multiple content types test:"), + ImageContent(type="image", data=TEST_IMAGE_BASE64, mimeType="image/png"), + EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=AnyUrl("test://mixed-content-resource"), + mimeType="application/json", + text='{"test": "data", "value": 123}', + ), + ), + ] + + +@mcp.tool() +async def test_tool_with_logging(ctx: Context[ServerSession, None]) -> str: + """Tests tool that emits log messages during execution""" + await ctx.info("Tool execution started") + await asyncio.sleep(0.05) + + await ctx.info("Tool processing data") + await asyncio.sleep(0.05) + + await ctx.info("Tool execution completed") + return "Tool with logging executed successfully" + + +@mcp.tool() +async def test_tool_with_progress(ctx: Context[ServerSession, None]) -> str: + """Tests tool that reports progress notifications""" + await ctx.report_progress(progress=0, total=100, message="Completed step 0 of 100") + await asyncio.sleep(0.05) + + await ctx.report_progress(progress=50, total=100, message="Completed step 50 of 100") + await asyncio.sleep(0.05) + + await ctx.report_progress(progress=100, total=100, message="Completed step 100 of 100") + + # Return progress token as string + progress_token = ctx.request_context.meta.progressToken if ctx.request_context and ctx.request_context.meta else 0 + return str(progress_token) + + +@mcp.tool() +async def test_sampling(prompt: str, ctx: Context[ServerSession, None]) -> str: + """Tests server-initiated sampling (LLM completion request)""" + try: + # Request sampling from client + result = await ctx.session.create_message( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], + max_tokens=100, + ) + + # Since we're not passing tools param, result.content is single content + if result.content.type == "text": + model_response = result.content.text + else: + model_response = "No response" + + return f"LLM response: {model_response}" + except Exception as e: + return f"Sampling not supported or error: {str(e)}" + + +class UserResponse(BaseModel): + response: str = Field(description="User's response") + + +@mcp.tool() +async def test_elicitation(message: str, ctx: Context[ServerSession, None]) -> str: + """Tests server-initiated elicitation (user input request)""" + try: + # Request user input from client + result = await ctx.elicit(message=message, schema=UserResponse) + + # Type-safe discriminated union narrowing using action field + if result.action == "accept": + content = result.data.model_dump_json() + else: # decline or cancel + content = "{}" + + return f"User response: action={result.action}, content={content}" + except Exception as e: + return f"Elicitation not supported or error: {str(e)}" + + +class SEP1034DefaultsSchema(BaseModel): + """Schema for testing SEP-1034 elicitation with default values for all primitive types""" + + name: str = Field(default="John Doe", description="User name") + age: int = Field(default=30, description="User age") + score: float = Field(default=95.5, description="User score") + status: str = Field( + default="active", + description="User status", + json_schema_extra={"enum": ["active", "inactive", "pending"]}, + ) + verified: bool = Field(default=True, description="Verification status") + + +@mcp.tool() +async def test_elicitation_sep1034_defaults(ctx: Context[ServerSession, None]) -> str: + """Tests elicitation with default values for all primitive types (SEP-1034)""" + try: + # Request user input with defaults for all primitive types + result = await ctx.elicit(message="Please provide user information", schema=SEP1034DefaultsSchema) + + # Type-safe discriminated union narrowing using action field + if result.action == "accept": + content = result.data.model_dump_json() + else: # decline or cancel + content = "{}" + + return f"Elicitation result: action={result.action}, content={content}" + except Exception as e: + return f"Elicitation not supported or error: {str(e)}" + + +class EnumSchemasTestSchema(BaseModel): + """Schema for testing enum schema variations (SEP-1330)""" + + untitledSingle: str = Field( + description="Simple enum without titles", json_schema_extra={"enum": ["active", "inactive", "pending"]} + ) + titledSingle: str = Field( + description="Enum with titled options (oneOf)", + json_schema_extra={ + "oneOf": [ + {"const": "low", "title": "Low Priority"}, + {"const": "medium", "title": "Medium Priority"}, + {"const": "high", "title": "High Priority"}, + ] + }, + ) + untitledMulti: list[str] = Field( + description="Multi-select without titles", + json_schema_extra={"items": {"type": "string", "enum": ["read", "write", "execute"]}}, + ) + titledMulti: list[str] = Field( + description="Multi-select with titled options", + json_schema_extra={ + "items": { + "anyOf": [ + {"const": "feature", "title": "New Feature"}, + {"const": "bug", "title": "Bug Fix"}, + {"const": "docs", "title": "Documentation"}, + ] + } + }, + ) + legacyEnum: str = Field( + description="Legacy enum with enumNames", + json_schema_extra={ + "enum": ["small", "medium", "large"], + "enumNames": ["Small Size", "Medium Size", "Large Size"], + }, + ) + + +@mcp.tool() +async def test_elicitation_sep1330_enums(ctx: Context[ServerSession, None]) -> str: + """Tests elicitation with enum schema variations per SEP-1330""" + try: + result = await ctx.elicit( + message="Please select values using different enum schema types", schema=EnumSchemasTestSchema + ) + + if result.action == "accept": + content = result.data.model_dump_json() + else: + content = "{}" + + return f"Elicitation completed: action={result.action}, content={content}" + except Exception as e: + return f"Elicitation not supported or error: {str(e)}" + + +@mcp.tool() +def test_error_handling() -> str: + """Tests error response handling""" + raise RuntimeError("This tool intentionally returns an error for testing") + + +@mcp.tool() +async def test_reconnection(ctx: Context[ServerSession, None]) -> str: + """Tests SSE polling by closing stream mid-call (SEP-1699)""" + await ctx.info("Before disconnect") + + await ctx.close_sse_stream() + + await asyncio.sleep(0.2) # Wait for client to reconnect + + await ctx.info("After reconnect") + return "Reconnection test completed" + + +# Resources +@mcp.resource("test://static-text") +def static_text_resource() -> str: + """A static text resource for testing""" + return "This is the content of the static text resource." + + +@mcp.resource("test://static-binary") +def static_binary_resource() -> bytes: + """A static binary resource (image) for testing""" + return base64.b64decode(TEST_IMAGE_BASE64) + + +@mcp.resource("test://template/{id}/data") +def template_resource(id: str) -> str: + """A resource template with parameter substitution""" + return json.dumps({"id": id, "templateTest": True, "data": f"Data for ID: {id}"}) + + +@mcp.resource("test://watched-resource") +def watched_resource() -> str: + """A resource that can be subscribed to for updates""" + return watched_resource_content + + +# Prompts +@mcp.prompt() +def test_simple_prompt() -> list[UserMessage]: + """A simple prompt without arguments""" + return [UserMessage(role="user", content=TextContent(type="text", text="This is a simple prompt for testing."))] + + +@mcp.prompt() +def test_prompt_with_arguments(arg1: str, arg2: str) -> list[UserMessage]: + """A prompt with required arguments""" + return [ + UserMessage( + role="user", content=TextContent(type="text", text=f"Prompt with arguments: arg1='{arg1}', arg2='{arg2}'") + ) + ] + + +@mcp.prompt() +def test_prompt_with_embedded_resource(resourceUri: str) -> list[UserMessage]: + """A prompt that includes an embedded resource""" + return [ + UserMessage( + role="user", + content=EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=AnyUrl(resourceUri), + mimeType="text/plain", + text="Embedded resource content for testing.", + ), + ), + ), + UserMessage(role="user", content=TextContent(type="text", text="Please process the embedded resource above.")), + ] + + +@mcp.prompt() +def test_prompt_with_image() -> list[UserMessage]: + """A prompt that includes image content""" + return [ + UserMessage(role="user", content=ImageContent(type="image", data=TEST_IMAGE_BASE64, mimeType="image/png")), + UserMessage(role="user", content=TextContent(type="text", text="Please analyze the image above.")), + ] + + +# Custom request handlers +# TODO(felix): Add public APIs to FastMCP for subscribe_resource, unsubscribe_resource, +# and set_logging_level to avoid accessing protected _mcp_server attribute. +@mcp._mcp_server.set_logging_level() # pyright: ignore[reportPrivateUsage] +async def handle_set_logging_level(level: str) -> None: + """Handle logging level changes""" + logger.info(f"Log level set to: {level}") + # In a real implementation, you would adjust the logging level here + # For conformance testing, we just acknowledge the request + + +async def handle_subscribe(uri: AnyUrl) -> None: + """Handle resource subscription""" + resource_subscriptions.add(str(uri)) + logger.info(f"Subscribed to resource: {uri}") + + +async def handle_unsubscribe(uri: AnyUrl) -> None: + """Handle resource unsubscription""" + resource_subscriptions.discard(str(uri)) + logger.info(f"Unsubscribed from resource: {uri}") + + +mcp._mcp_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage] +mcp._mcp_server.unsubscribe_resource()(handle_unsubscribe) # pyright: ignore[reportPrivateUsage] + + +@mcp.completion() +async def _handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion: + """Handle completion requests""" + # Basic completion support - returns empty array for conformance + # Real implementations would provide contextual suggestions + return Completion(values=[], total=0, hasMore=False) + + +# CLI +@click.command() +@click.option("--port", default=3001, help="Port to listen on for HTTP") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", +) +def main(port: int, log_level: str) -> int: + """Run the MCP Everything Server.""" + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + logger.info(f"Starting MCP Everything Server on port {port}") + logger.info(f"Endpoint will be: http://localhost:{port}/mcp") + + mcp.settings.port = port + mcp.run(transport="streamable-http") + + return 0 + + +if __name__ == "__main__": + main() diff --git a/examples/servers/everything-server/pyproject.toml b/examples/servers/everything-server/pyproject.toml new file mode 100644 index 0000000000..ff67bf5577 --- /dev/null +++ b/examples/servers/everything-server/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "mcp-everything-server" +version = "0.1.0" +description = "Comprehensive MCP server implementing all protocol features for conformance testing" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic, PBC." }] +keywords = ["mcp", "llm", "automation", "conformance", "testing"] +license = { text = "MIT" } +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-everything-server = "mcp_everything_server.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_everything_server"] + +[tool.pyright] +include = ["mcp_everything_server"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-auth/README.md b/examples/servers/simple-auth/README.md new file mode 100644 index 0000000000..b80e98a047 --- /dev/null +++ b/examples/servers/simple-auth/README.md @@ -0,0 +1,134 @@ +# MCP OAuth Authentication Demo + +This example demonstrates OAuth 2.0 authentication with the Model Context Protocol using **separate Authorization Server (AS) and Resource Server (RS)** to comply with the new RFC 9728 specification. + +--- + +## Running the Servers + +### Step 1: Start Authorization Server + +```bash +# Navigate to the simple-auth directory +cd examples/servers/simple-auth + +# Start Authorization Server on port 9000 +uv run mcp-simple-auth-as --port=9000 +``` + +**What it provides:** + +- OAuth 2.0 flows (registration, authorization, token exchange) +- Simple credential-based authentication (no external provider needed) +- Token introspection endpoint for Resource Servers (`/introspect`) + +--- + +### Step 2: Start Resource Server (MCP Server) + +```bash +# In another terminal, navigate to the simple-auth directory +cd examples/servers/simple-auth + +# Start Resource Server on port 8001, connected to Authorization Server +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http + +# With RFC 8707 strict resource validation (recommended for production) +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http --oauth-strict + +``` + +### Step 3: Test with Client + +```bash +cd examples/clients/simple-auth-client +# Start client with streamable HTTP +MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client +``` + +## How It Works + +### RFC 9728 Discovery + +**Client → Resource Server:** + +```bash +curl http://localhost:8001/.well-known/oauth-protected-resource +``` + +```json +{ + "resource": "http://localhost:8001", + "authorization_servers": ["http://localhost:9000"] +} +``` + +**Client → Authorization Server:** + +```bash +curl http://localhost:9000/.well-known/oauth-authorization-server +``` + +```json +{ + "issuer": "http://localhost:9000", + "authorization_endpoint": "http://localhost:9000/authorize", + "token_endpoint": "http://localhost:9000/token" +} +``` + +## Legacy MCP Server as Authorization Server (Backwards Compatibility) + +For backwards compatibility with older MCP implementations, a legacy server is provided that acts as an Authorization Server (following the old spec where MCP servers could optionally provide OAuth): + +### Running the Legacy Server + +```bash +# Start legacy authorization server on port 8002 +uv run mcp-simple-auth-legacy --port=8002 +``` + +**Differences from the new architecture:** + +- **MCP server acts as AS:** The MCP server itself provides OAuth endpoints (old spec behavior) +- **No separate RS:** The server handles both authentication and MCP tools +- **Local token validation:** Tokens are validated internally without introspection +- **No RFC 9728 support:** Does not provide `/.well-known/oauth-protected-resource` +- **Direct OAuth discovery:** OAuth metadata is at the MCP server's URL + +### Testing with Legacy Server + +```bash +# Test with client (will automatically fall back to legacy discovery) +cd examples/clients/simple-auth-client +MCP_SERVER_PORT=8002 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client +``` + +The client will: + +1. Try RFC 9728 discovery at `/.well-known/oauth-protected-resource` (404 on legacy server) +2. Fall back to direct OAuth discovery at `/.well-known/oauth-authorization-server` +3. Complete authentication with the MCP server acting as its own AS + +This ensures existing MCP servers (which could optionally act as Authorization Servers under the old spec) continue to work while the ecosystem transitions to the new architecture where MCP servers are Resource Servers only. + +## Manual Testing + +### Test Discovery + +```bash +# Test Resource Server discovery endpoint (new architecture) +curl -v http://localhost:8001/.well-known/oauth-protected-resource + +# Test Authorization Server metadata +curl -v http://localhost:9000/.well-known/oauth-authorization-server +``` + +### Test Token Introspection + +```bash +# After getting a token through OAuth flow: +curl -X POST http://localhost:9000/introspect \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=your_access_token" +``` diff --git a/examples/servers/simple-auth/mcp_simple_auth/__init__.py b/examples/servers/simple-auth/mcp_simple_auth/__init__.py new file mode 100644 index 0000000000..3e12b31832 --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/__init__.py @@ -0,0 +1 @@ +"""Simple MCP server with GitHub OAuth authentication.""" diff --git a/examples/servers/simple-auth/mcp_simple_auth/__main__.py b/examples/servers/simple-auth/mcp_simple_auth/__main__.py new file mode 100644 index 0000000000..2365ff5a1b --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/__main__.py @@ -0,0 +1,7 @@ +"""Main entry point for simple MCP server with GitHub OAuth authentication.""" + +import sys + +from mcp_simple_auth.server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/auth_server.py b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py new file mode 100644 index 0000000000..80a2e8b8a3 --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py @@ -0,0 +1,187 @@ +""" +Authorization Server for MCP Split Demo. + +This server handles OAuth flows, client registration, and token issuance. +Can be replaced with enterprise authorization servers like Auth0, Entra ID, etc. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. + +""" + +import asyncio +import logging +import time + +import click +from pydantic import AnyHttpUrl, BaseModel +from starlette.applications import Starlette +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route +from uvicorn import Config, Server + +from mcp.server.auth.routes import cors_middleware, create_auth_routes +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions + +from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider + +logger = logging.getLogger(__name__) + + +class AuthServerSettings(BaseModel): + """Settings for the Authorization Server.""" + + # Server settings + host: str = "localhost" + port: int = 9000 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + auth_callback_path: str = "http://localhost:9000/login/callback" + + +class SimpleAuthProvider(SimpleOAuthProvider): + """ + Authorization Server provider with simple demo authentication. + + This provider: + 1. Issues MCP tokens after simple credential authentication + 2. Stores token state for introspection by Resource Servers + """ + + def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str): + super().__init__(auth_settings, auth_callback_path, server_url) + + +def create_authorization_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings) -> Starlette: + """Create the Authorization Server application.""" + oauth_provider = SimpleAuthProvider( + auth_settings, server_settings.auth_callback_path, str(server_settings.server_url) + ) + + mcp_auth_settings = AuthSettings( + issuer_url=server_settings.server_url, + client_registration_options=ClientRegistrationOptions( + enabled=True, + valid_scopes=[auth_settings.mcp_scope], + default_scopes=[auth_settings.mcp_scope], + ), + required_scopes=[auth_settings.mcp_scope], + resource_server_url=None, + ) + + # Create OAuth routes + routes = create_auth_routes( + provider=oauth_provider, + issuer_url=mcp_auth_settings.issuer_url, + service_documentation_url=mcp_auth_settings.service_documentation_url, + client_registration_options=mcp_auth_settings.client_registration_options, + revocation_options=mcp_auth_settings.revocation_options, + ) + + # Add login page route (GET) + async def login_page_handler(request: Request) -> Response: + """Show login form.""" + state = request.query_params.get("state") + if not state: + raise HTTPException(400, "Missing state parameter") + return await oauth_provider.get_login_page(state) + + routes.append(Route("/login", endpoint=login_page_handler, methods=["GET"])) + + # Add login callback route (POST) + async def login_callback_handler(request: Request) -> Response: + """Handle simple authentication callback.""" + return await oauth_provider.handle_login_callback(request) + + routes.append(Route("/login/callback", endpoint=login_callback_handler, methods=["POST"])) + + # Add token introspection endpoint (RFC 7662) for Resource Servers + async def introspect_handler(request: Request) -> Response: + """ + Token introspection endpoint for Resource Servers. + + Resource Servers call this endpoint to validate tokens without + needing direct access to token storage. + """ + form = await request.form() + token = form.get("token") + if not token or not isinstance(token, str): + return JSONResponse({"active": False}, status_code=400) + + # Look up token in provider + access_token = await oauth_provider.load_access_token(token) + if not access_token: + return JSONResponse({"active": False}) + + return JSONResponse( + { + "active": True, + "client_id": access_token.client_id, + "scope": " ".join(access_token.scopes), + "exp": access_token.expires_at, + "iat": int(time.time()), + "token_type": "Bearer", + "aud": access_token.resource, # RFC 8707 audience claim + } + ) + + routes.append( + Route( + "/introspect", + endpoint=cors_middleware(introspect_handler, ["POST", "OPTIONS"]), + methods=["POST", "OPTIONS"], + ) + ) + + return Starlette(routes=routes) + + +async def run_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings): + """Run the Authorization Server.""" + auth_server = create_authorization_server(server_settings, auth_settings) + + config = Config( + auth_server, + host=server_settings.host, + port=server_settings.port, + log_level="info", + ) + server = Server(config) + + logger.info(f"🚀 MCP Authorization Server running on {server_settings.server_url}") + + await server.serve() + + +@click.command() +@click.option("--port", default=9000, help="Port to listen on") +def main(port: int) -> int: + """ + Run the MCP Authorization Server. + + This server handles OAuth flows and can be used by multiple Resource Servers. + + Uses simple hardcoded credentials for demo purposes. + """ + logging.basicConfig(level=logging.INFO) + + # Load simple auth settings + auth_settings = SimpleAuthSettings() + + # Create server settings + host = "localhost" + server_url = f"http://{host}:{port}" + server_settings = AuthServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_callback_path=f"{server_url}/login", + ) + + asyncio.run(run_server(server_settings, auth_settings)) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py b/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py new file mode 100644 index 0000000000..b0455c3e89 --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py @@ -0,0 +1,139 @@ +""" +Legacy Combined Authorization Server + Resource Server for MCP. + +This server implements the old spec where MCP servers could act as both AS and RS. +Used for backwards compatibility testing with the new split AS/RS architecture. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. + +""" + +import datetime +import logging +from typing import Any, Literal + +import click +from pydantic import AnyHttpUrl, BaseModel +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import Response + +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions +from mcp.server.fastmcp.server import FastMCP + +from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider + +logger = logging.getLogger(__name__) + + +class ServerSettings(BaseModel): + """Settings for the simple auth MCP server.""" + + # Server settings + host: str = "localhost" + port: int = 8000 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000") + auth_callback_path: str = "http://localhost:8000/login/callback" + + +class LegacySimpleOAuthProvider(SimpleOAuthProvider): + """Simple OAuth provider for legacy MCP server.""" + + def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str): + super().__init__(auth_settings, auth_callback_path, server_url) + + +def create_simple_mcp_server(server_settings: ServerSettings, auth_settings: SimpleAuthSettings) -> FastMCP: + """Create a simple FastMCP server with simple authentication.""" + oauth_provider = LegacySimpleOAuthProvider( + auth_settings, server_settings.auth_callback_path, str(server_settings.server_url) + ) + + mcp_auth_settings = AuthSettings( + issuer_url=server_settings.server_url, + client_registration_options=ClientRegistrationOptions( + enabled=True, + valid_scopes=[auth_settings.mcp_scope], + default_scopes=[auth_settings.mcp_scope], + ), + required_scopes=[auth_settings.mcp_scope], + # No resource_server_url parameter in legacy mode + resource_server_url=None, + ) + + app = FastMCP( + name="Simple Auth MCP Server", + instructions="A simple MCP server with simple credential authentication", + auth_server_provider=oauth_provider, + host=server_settings.host, + port=server_settings.port, + debug=True, + auth=mcp_auth_settings, + ) + + @app.custom_route("/login", methods=["GET"]) + async def login_page_handler(request: Request) -> Response: + """Show login form.""" + state = request.query_params.get("state") + if not state: + raise HTTPException(400, "Missing state parameter") + return await oauth_provider.get_login_page(state) + + @app.custom_route("/login/callback", methods=["POST"]) + async def login_callback_handler(request: Request) -> Response: + """Handle simple authentication callback.""" + return await oauth_provider.handle_login_callback(request) + + @app.tool() + async def get_time() -> dict[str, Any]: + """ + Get the current server time. + + This tool demonstrates that system information can be protected + by OAuth authentication. User must be authenticated to access it. + """ + + now = datetime.datetime.now() + + return { + "current_time": now.isoformat(), + "timezone": "UTC", # Simplified for demo + "timestamp": now.timestamp(), + "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), + } + + return app + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on") +@click.option( + "--transport", + default="streamable-http", + type=click.Choice(["sse", "streamable-http"]), + help="Transport protocol to use ('sse' or 'streamable-http')", +) +def main(port: int, transport: Literal["sse", "streamable-http"]) -> int: + """Run the simple auth MCP server.""" + logging.basicConfig(level=logging.INFO) + + auth_settings = SimpleAuthSettings() + # Create server settings + host = "localhost" + server_url = f"http://{host}:{port}" + server_settings = ServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_callback_path=f"{server_url}/login", + ) + + mcp_server = create_simple_mcp_server(server_settings, auth_settings) + logger.info(f"🚀 MCP Legacy Server running on {server_url}") + mcp_server.run(transport=transport) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/server.py b/examples/servers/simple-auth/mcp_simple_auth/server.py new file mode 100644 index 0000000000..5d88505708 --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/server.py @@ -0,0 +1,165 @@ +""" +MCP Resource Server with Token Introspection. + +This server validates tokens via Authorization Server introspection and serves MCP resources. +Demonstrates RFC 9728 Protected Resource Metadata for AS/RS separation. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. +""" + +import datetime +import logging +from typing import Any, Literal + +import click +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict + +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp.server import FastMCP + +from .token_verifier import IntrospectionTokenVerifier + +logger = logging.getLogger(__name__) + + +class ResourceServerSettings(BaseSettings): + """Settings for the MCP Resource Server.""" + + model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") + + # Server settings + host: str = "localhost" + port: int = 8001 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001/mcp") + + # Authorization Server settings + auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + auth_server_introspection_endpoint: str = "http://localhost:9000/introspect" + # No user endpoint needed - we get user data from token introspection + + # MCP settings + mcp_scope: str = "user" + + # RFC 8707 resource validation + oauth_strict: bool = False + + +def create_resource_server(settings: ResourceServerSettings) -> FastMCP: + """ + Create MCP Resource Server with token introspection. + + This server: + 1. Provides protected resource metadata (RFC 9728) + 2. Validates tokens via Authorization Server introspection + 3. Serves MCP tools and resources + """ + # Create token verifier for introspection with RFC 8707 resource validation + token_verifier = IntrospectionTokenVerifier( + introspection_endpoint=settings.auth_server_introspection_endpoint, + server_url=str(settings.server_url), + validate_resource=settings.oauth_strict, # Only validate when --oauth-strict is set + ) + + # Create FastMCP server as a Resource Server + app = FastMCP( + name="MCP Resource Server", + instructions="Resource Server that validates tokens via Authorization Server introspection", + host=settings.host, + port=settings.port, + debug=True, + # Auth configuration for RS mode + token_verifier=token_verifier, + auth=AuthSettings( + issuer_url=settings.auth_server_url, + required_scopes=[settings.mcp_scope], + resource_server_url=settings.server_url, + ), + ) + + @app.tool() + async def get_time() -> dict[str, Any]: + """ + Get the current server time. + + This tool demonstrates that system information can be protected + by OAuth authentication. User must be authenticated to access it. + """ + + now = datetime.datetime.now() + + return { + "current_time": now.isoformat(), + "timezone": "UTC", # Simplified for demo + "timestamp": now.timestamp(), + "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), + } + + return app + + +@click.command() +@click.option("--port", default=8001, help="Port to listen on") +@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") +@click.option( + "--transport", + default="streamable-http", + type=click.Choice(["sse", "streamable-http"]), + help="Transport protocol to use ('sse' or 'streamable-http')", +) +@click.option( + "--oauth-strict", + is_flag=True, + help="Enable RFC 8707 resource validation", +) +def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"], oauth_strict: bool) -> int: + """ + Run the MCP Resource Server. + + This server: + - Provides RFC 9728 Protected Resource Metadata + - Validates tokens via Authorization Server introspection + - Serves MCP tools requiring authentication + + Must be used with a running Authorization Server. + """ + logging.basicConfig(level=logging.INFO) + + try: + # Parse auth server URL + auth_server_url = AnyHttpUrl(auth_server) + + # Create settings + host = "localhost" + server_url = f"http://{host}:{port}/mcp" + settings = ResourceServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_server_url=auth_server_url, + auth_server_introspection_endpoint=f"{auth_server}/introspect", + oauth_strict=oauth_strict, + ) + except ValueError as e: + logger.error(f"Configuration error: {e}") + logger.error("Make sure to provide a valid Authorization Server URL") + return 1 + + try: + mcp_server = create_resource_server(settings) + + logger.info(f"🚀 MCP Resource Server running on {settings.server_url}") + logger.info(f"🔑 Using Authorization Server: {settings.auth_server_url}") + + # Run the server - this should block and keep running + mcp_server.run(transport=transport) + logger.info("Server stopped") + return 0 + except Exception: + logger.exception("Server error") + return 1 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py new file mode 100644 index 0000000000..e3a25d3e8c --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py @@ -0,0 +1,275 @@ +""" +Simple OAuth provider for MCP servers. + +This module contains a basic OAuth implementation using hardcoded user credentials +for demonstration purposes. No external authentication provider is required. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. + +""" + +import logging +import secrets +import time +from typing import Any + +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import HTMLResponse, RedirectResponse, Response + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + OAuthAuthorizationServerProvider, + RefreshToken, + construct_redirect_uri, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + +logger = logging.getLogger(__name__) + + +class SimpleAuthSettings(BaseSettings): + """Simple OAuth settings for demo purposes.""" + + model_config = SettingsConfigDict(env_prefix="MCP_") + + # Demo user credentials + demo_username: str = "demo_user" + demo_password: str = "demo_password" + + # MCP OAuth scope + mcp_scope: str = "user" + + +class SimpleOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): + """ + Simple OAuth provider for demo purposes. + + This provider handles the OAuth flow by: + 1. Providing a simple login form for demo credentials + 2. Issuing MCP tokens after successful authentication + 3. Maintaining token state for introspection + """ + + def __init__(self, settings: SimpleAuthSettings, auth_callback_url: str, server_url: str): + self.settings = settings + self.auth_callback_url = auth_callback_url + self.server_url = server_url + self.clients: dict[str, OAuthClientInformationFull] = {} + self.auth_codes: dict[str, AuthorizationCode] = {} + self.tokens: dict[str, AccessToken] = {} + self.state_mapping: dict[str, dict[str, str | None]] = {} + # Store authenticated user information + self.user_data: dict[str, dict[str, Any]] = {} + + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + """Get OAuth client information.""" + return self.clients.get(client_id) + + async def register_client(self, client_info: OAuthClientInformationFull): + """Register a new OAuth client.""" + if not client_info.client_id: + raise ValueError("No client_id provided") + self.clients[client_info.client_id] = client_info + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + """Generate an authorization URL for simple login flow.""" + state = params.state or secrets.token_hex(16) + + # Store state mapping for callback + self.state_mapping[state] = { + "redirect_uri": str(params.redirect_uri), + "code_challenge": params.code_challenge, + "redirect_uri_provided_explicitly": str(params.redirect_uri_provided_explicitly), + "client_id": client.client_id, + "resource": params.resource, # RFC 8707 + } + + # Build simple login URL that points to login page + auth_url = f"{self.auth_callback_url}?state={state}&client_id={client.client_id}" + + return auth_url + + async def get_login_page(self, state: str) -> HTMLResponse: + """Generate login page HTML for the given state.""" + if not state: + raise HTTPException(400, "Missing state parameter") + + # Create simple login form HTML + html_content = f""" + + + + MCP Demo Authentication + + + +

MCP Demo Authentication

+

This is a simplified authentication demo. Use the demo credentials below:

+

Username: demo_user
+ Password: demo_password

+ +
+ +
+ + +
+
+ + +
+ +
+ + + """ + + return HTMLResponse(content=html_content) + + async def handle_login_callback(self, request: Request) -> Response: + """Handle login form submission callback.""" + form = await request.form() + username = form.get("username") + password = form.get("password") + state = form.get("state") + + if not username or not password or not state: + raise HTTPException(400, "Missing username, password, or state parameter") + + # Ensure we have strings, not UploadFile objects + if not isinstance(username, str) or not isinstance(password, str) or not isinstance(state, str): + raise HTTPException(400, "Invalid parameter types") + + redirect_uri = await self.handle_simple_callback(username, password, state) + return RedirectResponse(url=redirect_uri, status_code=302) + + async def handle_simple_callback(self, username: str, password: str, state: str) -> str: + """Handle simple authentication callback and return redirect URI.""" + state_data = self.state_mapping.get(state) + if not state_data: + raise HTTPException(400, "Invalid state parameter") + + redirect_uri = state_data["redirect_uri"] + code_challenge = state_data["code_challenge"] + redirect_uri_provided_explicitly = state_data["redirect_uri_provided_explicitly"] == "True" + client_id = state_data["client_id"] + resource = state_data.get("resource") # RFC 8707 + + # These are required values from our own state mapping + assert redirect_uri is not None + assert code_challenge is not None + assert client_id is not None + + # Validate demo credentials + if username != self.settings.demo_username or password != self.settings.demo_password: + raise HTTPException(401, "Invalid credentials") + + # Create MCP authorization code + new_code = f"mcp_{secrets.token_hex(16)}" + auth_code = AuthorizationCode( + code=new_code, + client_id=client_id, + redirect_uri=AnyHttpUrl(redirect_uri), + redirect_uri_provided_explicitly=redirect_uri_provided_explicitly, + expires_at=time.time() + 300, + scopes=[self.settings.mcp_scope], + code_challenge=code_challenge, + resource=resource, # RFC 8707 + ) + self.auth_codes[new_code] = auth_code + + # Store user data + self.user_data[username] = { + "username": username, + "user_id": f"user_{secrets.token_hex(8)}", + "authenticated_at": time.time(), + } + + del self.state_mapping[state] + return construct_redirect_uri(redirect_uri, code=new_code, state=state) + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> AuthorizationCode | None: + """Load an authorization code.""" + return self.auth_codes.get(authorization_code) + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + """Exchange authorization code for tokens.""" + if authorization_code.code not in self.auth_codes: + raise ValueError("Invalid authorization code") + if not client.client_id: + raise ValueError("No client_id provided") + + # Generate MCP access token + mcp_token = f"mcp_{secrets.token_hex(32)}" + + # Store MCP token + self.tokens[mcp_token] = AccessToken( + token=mcp_token, + client_id=client.client_id, + scopes=authorization_code.scopes, + expires_at=int(time.time()) + 3600, + resource=authorization_code.resource, # RFC 8707 + ) + + # Store user data mapping for this token + self.user_data[mcp_token] = { + "username": self.settings.demo_username, + "user_id": f"user_{secrets.token_hex(8)}", + "authenticated_at": time.time(), + } + + del self.auth_codes[authorization_code.code] + + return OAuthToken( + access_token=mcp_token, + token_type="Bearer", + expires_in=3600, + scope=" ".join(authorization_code.scopes), + ) + + async def load_access_token(self, token: str) -> AccessToken | None: + """Load and validate an access token.""" + access_token = self.tokens.get(token) + if not access_token: + return None + + # Check if expired + if access_token.expires_at and access_token.expires_at < time.time(): + del self.tokens[token] + return None + + return access_token + + async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None: + """Load a refresh token - not supported in this example.""" + return None + + async def exchange_refresh_token( + self, + client: OAuthClientInformationFull, + refresh_token: RefreshToken, + scopes: list[str], + ) -> OAuthToken: + """Exchange refresh token - not supported in this example.""" + raise NotImplementedError("Refresh tokens not supported") + + # TODO(Marcelo): The type hint is wrong. We need to fix, and test to check if it works. + async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None: # type: ignore + """Revoke a token.""" + if token in self.tokens: + del self.tokens[token] diff --git a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py new file mode 100644 index 0000000000..5228d034e4 --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py @@ -0,0 +1,106 @@ +"""Example token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662).""" + +import logging +from typing import Any + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url + +logger = logging.getLogger(__name__) + + +class IntrospectionTokenVerifier(TokenVerifier): + """Example token verifier that uses OAuth 2.0 Token Introspection (RFC 7662). + + This is a simple example implementation for demonstration purposes. + Production implementations should consider: + - Connection pooling and reuse + - More sophisticated error handling + - Rate limiting and retry logic + - Comprehensive configuration options + """ + + def __init__( + self, + introspection_endpoint: str, + server_url: str, + validate_resource: bool = False, + ): + self.introspection_endpoint = introspection_endpoint + self.server_url = server_url + self.validate_resource = validate_resource + self.resource_url = resource_url_from_server_url(server_url) + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token via introspection endpoint.""" + import httpx + + # Validate URL to prevent SSRF attacks + if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): + logger.warning(f"Rejecting introspection endpoint with unsafe scheme: {self.introspection_endpoint}") + return None + + # Configure secure HTTP client + timeout = httpx.Timeout(10.0, connect=5.0) + limits = httpx.Limits(max_connections=10, max_keepalive_connections=5) + + async with httpx.AsyncClient( + timeout=timeout, + limits=limits, + verify=True, # Enforce SSL verification + ) as client: + try: + response = await client.post( + self.introspection_endpoint, + data={"token": token}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + if response.status_code != 200: + logger.debug(f"Token introspection returned status {response.status_code}") + return None + + data = response.json() + if not data.get("active", False): + return None + + # RFC 8707 resource validation (only when --oauth-strict is set) + if self.validate_resource and not self._validate_resource(data): + logger.warning(f"Token resource validation failed. Expected: {self.resource_url}") + return None + + return AccessToken( + token=token, + client_id=data.get("client_id", "unknown"), + scopes=data.get("scope", "").split() if data.get("scope") else [], + expires_at=data.get("exp"), + resource=data.get("aud"), # Include resource in token + ) + except Exception as e: + logger.warning(f"Token introspection failed: {e}") + return None + + def _validate_resource(self, token_data: dict[str, Any]) -> bool: + """Validate token was issued for this resource server.""" + if not self.server_url or not self.resource_url: + return False # Fail if strict validation requested but URLs missing + + # Check 'aud' claim first (standard JWT audience) + aud: list[str] | str | None = token_data.get("aud") + if isinstance(aud, list): + for audience in aud: + if self._is_valid_resource(audience): + return True + return False + elif aud: + return self._is_valid_resource(aud) + + # No resource binding - invalid per RFC 8707 + return False + + def _is_valid_resource(self, resource: str) -> bool: + """Check if resource matches this server using hierarchical matching.""" + if not self.resource_url: + return False + + return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource) diff --git a/examples/servers/simple-auth/pyproject.toml b/examples/servers/simple-auth/pyproject.toml new file mode 100644 index 0000000000..eb2b18561b --- /dev/null +++ b/examples/servers/simple-auth/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "mcp-simple-auth" +version = "0.1.0" +description = "A simple MCP server demonstrating OAuth authentication" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic, PBC." }] +license = { text = "MIT" } +dependencies = [ + "anyio>=4.5", + "click>=8.2.0", + "httpx>=0.27", + "mcp", + "pydantic>=2.0", + "pydantic-settings>=2.5.2", + "sse-starlette>=1.6.1", + "uvicorn>=0.23.1; sys_platform != 'emscripten'", +] + +[project.scripts] +mcp-simple-auth-rs = "mcp_simple_auth.server:main" +mcp-simple-auth-as = "mcp_simple_auth.auth_server:main" +mcp-simple-auth-legacy = "mcp_simple_auth.legacy_as_server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_auth"] + +[dependency-groups] +dev = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"] diff --git a/examples/servers/simple-pagination/README.md b/examples/servers/simple-pagination/README.md new file mode 100644 index 0000000000..e732b8efbe --- /dev/null +++ b/examples/servers/simple-pagination/README.md @@ -0,0 +1,77 @@ +# MCP Simple Pagination + +A simple MCP server demonstrating pagination for tools, resources, and prompts using cursor-based pagination. + +## Usage + +Start the server using either stdio (default) or SSE transport: + +```bash +# Using stdio transport (default) +uv run mcp-simple-pagination + +# Using SSE transport on custom port +uv run mcp-simple-pagination --transport sse --port 8000 +``` + +The server exposes: + +- 25 tools (paginated, 5 per page) +- 30 resources (paginated, 10 per page) +- 20 prompts (paginated, 7 per page) + +Each paginated list returns a `nextCursor` when more pages are available. Use this cursor in subsequent requests to retrieve the next page. + +## Example + +Using the MCP client, you can retrieve paginated items like this using the STDIO transport: + +```python +import asyncio +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +async def main(): + async with stdio_client( + StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"]) + ) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # Get first page of tools + tools_page1 = await session.list_tools() + print(f"First page: {len(tools_page1.tools)} tools") + print(f"Next cursor: {tools_page1.nextCursor}") + + # Get second page using cursor + if tools_page1.nextCursor: + tools_page2 = await session.list_tools(cursor=tools_page1.nextCursor) + print(f"Second page: {len(tools_page2.tools)} tools") + + # Similarly for resources + resources_page1 = await session.list_resources() + print(f"First page: {len(resources_page1.resources)} resources") + + # And for prompts + prompts_page1 = await session.list_prompts() + print(f"First page: {len(prompts_page1.prompts)} prompts") + + +asyncio.run(main()) +``` + +## Pagination Details + +The server uses simple numeric indices as cursors for demonstration purposes. In production scenarios, you might use: + +- Database offsets or row IDs +- Timestamps for time-based pagination +- Opaque tokens encoding pagination state + +The pagination implementation demonstrates: + +- Handling `None` cursor for the first page +- Returning `nextCursor` when more data exists +- Gracefully handling invalid cursors +- Different page sizes for different resource types diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/__init__.py b/examples/servers/simple-pagination/mcp_simple_pagination/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py b/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py new file mode 100644 index 0000000000..e7ef16530b --- /dev/null +++ b/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/server.py b/examples/servers/simple-pagination/mcp_simple_pagination/server.py new file mode 100644 index 0000000000..360cbc3cff --- /dev/null +++ b/examples/servers/simple-pagination/mcp_simple_pagination/server.py @@ -0,0 +1,228 @@ +""" +Simple MCP server demonstrating pagination for tools, resources, and prompts. + +This example shows how to use the paginated decorators to handle large lists +of items that need to be split across multiple pages. +""" + +from typing import Any + +import anyio +import click +import mcp.types as types +from mcp.server.lowlevel import Server +from pydantic import AnyUrl +from starlette.requests import Request + +# Sample data - in real scenarios, this might come from a database +SAMPLE_TOOLS = [ + types.Tool( + name=f"tool_{i}", + title=f"Tool {i}", + description=f"This is sample tool number {i}", + inputSchema={"type": "object", "properties": {"input": {"type": "string"}}}, + ) + for i in range(1, 26) # 25 tools total +] + +SAMPLE_RESOURCES = [ + types.Resource( + uri=AnyUrl(f"file:///path/to/resource_{i}.txt"), + name=f"resource_{i}", + description=f"This is sample resource number {i}", + ) + for i in range(1, 31) # 30 resources total +] + +SAMPLE_PROMPTS = [ + types.Prompt( + name=f"prompt_{i}", + description=f"This is sample prompt number {i}", + arguments=[ + types.PromptArgument(name="arg1", description="First argument", required=True), + ], + ) + for i in range(1, 21) # 20 prompts total +] + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on for SSE") +@click.option( + "--transport", + type=click.Choice(["stdio", "sse"]), + default="stdio", + help="Transport type", +) +def main(port: int, transport: str) -> int: + app = Server("mcp-simple-pagination") + + # Paginated list_tools - returns 5 tools per page + @app.list_tools() + async def list_tools_paginated(request: types.ListToolsRequest) -> types.ListToolsResult: + page_size = 5 + + cursor = request.params.cursor if request.params is not None else None + if cursor is None: + # First page + start_idx = 0 + else: + # Parse cursor to get the start index + try: + start_idx = int(cursor) + except (ValueError, TypeError): + # Invalid cursor, return empty + return types.ListToolsResult(tools=[], nextCursor=None) + + # Get the page of tools + page_tools = SAMPLE_TOOLS[start_idx : start_idx + page_size] + + # Determine if there are more pages + next_cursor = None + if start_idx + page_size < len(SAMPLE_TOOLS): + next_cursor = str(start_idx + page_size) + + return types.ListToolsResult(tools=page_tools, nextCursor=next_cursor) + + # Paginated list_resources - returns 10 resources per page + @app.list_resources() + async def list_resources_paginated( + request: types.ListResourcesRequest, + ) -> types.ListResourcesResult: + page_size = 10 + + cursor = request.params.cursor if request.params is not None else None + if cursor is None: + # First page + start_idx = 0 + else: + # Parse cursor to get the start index + try: + start_idx = int(cursor) + except (ValueError, TypeError): + # Invalid cursor, return empty + return types.ListResourcesResult(resources=[], nextCursor=None) + + # Get the page of resources + page_resources = SAMPLE_RESOURCES[start_idx : start_idx + page_size] + + # Determine if there are more pages + next_cursor = None + if start_idx + page_size < len(SAMPLE_RESOURCES): + next_cursor = str(start_idx + page_size) + + return types.ListResourcesResult(resources=page_resources, nextCursor=next_cursor) + + # Paginated list_prompts - returns 7 prompts per page + @app.list_prompts() + async def list_prompts_paginated( + request: types.ListPromptsRequest, + ) -> types.ListPromptsResult: + page_size = 7 + + cursor = request.params.cursor if request.params is not None else None + if cursor is None: + # First page + start_idx = 0 + else: + # Parse cursor to get the start index + try: + start_idx = int(cursor) + except (ValueError, TypeError): + # Invalid cursor, return empty + return types.ListPromptsResult(prompts=[], nextCursor=None) + + # Get the page of prompts + page_prompts = SAMPLE_PROMPTS[start_idx : start_idx + page_size] + + # Determine if there are more pages + next_cursor = None + if start_idx + page_size < len(SAMPLE_PROMPTS): + next_cursor = str(start_idx + page_size) + + return types.ListPromptsResult(prompts=page_prompts, nextCursor=next_cursor) + + # Implement call_tool handler + @app.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: + # Find the tool in our sample data + tool = next((t for t in SAMPLE_TOOLS if t.name == name), None) + if not tool: + raise ValueError(f"Unknown tool: {name}") + + # Simple mock response + return [ + types.TextContent( + type="text", + text=f"Called tool '{name}' with arguments: {arguments}", + ) + ] + + # Implement read_resource handler + @app.read_resource() + async def read_resource(uri: AnyUrl) -> str: + # Find the resource in our sample data + resource = next((r for r in SAMPLE_RESOURCES if r.uri == uri), None) + if not resource: + raise ValueError(f"Unknown resource: {uri}") + + # Return a simple string - the decorator will convert it to TextResourceContents + return f"Content of {resource.name}: This is sample content for the resource." + + # Implement get_prompt handler + @app.get_prompt() + async def get_prompt(name: str, arguments: dict[str, str] | None) -> types.GetPromptResult: + # Find the prompt in our sample data + prompt = next((p for p in SAMPLE_PROMPTS if p.name == name), None) + if not prompt: + raise ValueError(f"Unknown prompt: {name}") + + # Simple mock response + message_text = f"This is the prompt '{name}'" + if arguments: + message_text += f" with arguments: {arguments}" + + return types.GetPromptResult( + description=prompt.description, + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text=message_text), + ) + ], + ) + + if transport == "sse": + from mcp.server.sse import SseServerTransport + from starlette.applications import Starlette + from starlette.responses import Response + from starlette.routing import Mount, Route + + sse = SseServerTransport("/messages/") + + async def handle_sse(request: Request): + async with sse.connect_sse(request.scope, request.receive, request._send) as streams: # type: ignore[reportPrivateUsage] + await app.run(streams[0], streams[1], app.create_initialization_options()) + return Response() + + starlette_app = Starlette( + debug=True, + routes=[ + Route("/sse", endpoint=handle_sse, methods=["GET"]), + Mount("/messages/", app=sse.handle_post_message), + ], + ) + + import uvicorn + + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + else: + from mcp.server.stdio import stdio_server + + async def arun(): + async with stdio_server() as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + + anyio.run(arun) + + return 0 diff --git a/examples/servers/simple-pagination/pyproject.toml b/examples/servers/simple-pagination/pyproject.toml new file mode 100644 index 0000000000..14de502574 --- /dev/null +++ b/examples/servers/simple-pagination/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "mcp-simple-pagination" +version = "0.1.0" +description = "A simple MCP server demonstrating pagination for tools, resources, and prompts" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic, PBC." }] +maintainers = [ + { name = "David Soria Parra", email = "davidsp@anthropic.com" }, + { name = "Justin Spahr-Summers", email = "justin@anthropic.com" }, +] +keywords = ["mcp", "llm", "automation", "pagination", "cursor"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] + +[project.scripts] +mcp-simple-pagination = "mcp_simple_pagination.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_pagination"] + +[tool.pyright] +include = ["mcp_simple_pagination"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/__init__.py b/examples/servers/simple-prompt/mcp_simple_prompt/__init__.py index 8b13789179..e69de29bb2 100644 --- a/examples/servers/simple-prompt/mcp_simple_prompt/__init__.py +++ b/examples/servers/simple-prompt/mcp_simple_prompt/__init__.py @@ -1 +0,0 @@ - diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py b/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py index 8b345fa2e5..e7ef16530b 100644 --- a/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py +++ b/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py @@ -2,4 +2,4 @@ from .server import main -sys.exit(main()) +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/server.py b/examples/servers/simple-prompt/mcp_simple_prompt/server.py index 0552f2770e..76b598f931 100644 --- a/examples/servers/simple-prompt/mcp_simple_prompt/server.py +++ b/examples/servers/simple-prompt/mcp_simple_prompt/server.py @@ -2,22 +2,19 @@ import click import mcp.types as types from mcp.server.lowlevel import Server +from starlette.requests import Request -def create_messages( - context: str | None = None, topic: str | None = None -) -> list[types.PromptMessage]: +def create_messages(context: str | None = None, topic: str | None = None) -> list[types.PromptMessage]: """Create the messages for the prompt.""" - messages = [] + messages: list[types.PromptMessage] = [] # Add context if provided if context: messages.append( types.PromptMessage( role="user", - content=types.TextContent( - type="text", text=f"Here is some relevant context: {context}" - ), + content=types.TextContent(type="text", text=f"Here is some relevant context: {context}"), ) ) @@ -28,11 +25,7 @@ def create_messages( else: prompt += "whatever questions I may have." - messages.append( - types.PromptMessage( - role="user", content=types.TextContent(type="text", text=prompt) - ) - ) + messages.append(types.PromptMessage(role="user", content=types.TextContent(type="text", text=prompt))) return messages @@ -53,8 +46,8 @@ async def list_prompts() -> list[types.Prompt]: return [ types.Prompt( name="simple", - description="A simple prompt that can take optional context and topic " - "arguments", + title="Simple Assistant Prompt", + description="A simple prompt that can take optional context and topic arguments", arguments=[ types.PromptArgument( name="context", @@ -71,9 +64,7 @@ async def list_prompts() -> list[types.Prompt]: ] @app.get_prompt() - async def get_prompt( - name: str, arguments: dict[str, str] | None = None - ) -> types.GetPromptResult: + async def get_prompt(name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult: if name != "simple": raise ValueError(f"Unknown prompt: {name}") @@ -81,26 +72,22 @@ async def get_prompt( arguments = {} return types.GetPromptResult( - messages=create_messages( - context=arguments.get("context"), topic=arguments.get("topic") - ), + messages=create_messages(context=arguments.get("context"), topic=arguments.get("topic")), description="A simple prompt with optional context and topic arguments", ) if transport == "sse": from mcp.server.sse import SseServerTransport from starlette.applications import Starlette + from starlette.responses import Response from starlette.routing import Mount, Route sse = SseServerTransport("/messages/") - async def handle_sse(request): - async with sse.connect_sse( - request.scope, request.receive, request._send - ) as streams: - await app.run( - streams[0], streams[1], app.create_initialization_options() - ) + async def handle_sse(request: Request): + async with sse.connect_sse(request.scope, request.receive, request._send) as streams: # type: ignore[reportPrivateUsage] + await app.run(streams[0], streams[1], app.create_initialization_options()) + return Response() starlette_app = Starlette( debug=True, @@ -112,15 +99,13 @@ async def handle_sse(request): import uvicorn - uvicorn.run(starlette_app, host="0.0.0.0", port=port) + uvicorn.run(starlette_app, host="127.0.0.1", port=port) else: from mcp.server.stdio import stdio_server async def arun(): async with stdio_server() as streams: - await app.run( - streams[0], streams[1], app.create_initialization_options() - ) + await app.run(streams[0], streams[1], app.create_initialization_options()) anyio.run(arun) diff --git a/examples/servers/simple-prompt/pyproject.toml b/examples/servers/simple-prompt/pyproject.toml index 1ef968d409..28fe265746 100644 --- a/examples/servers/simple-prompt/pyproject.toml +++ b/examples/servers/simple-prompt/pyproject.toml @@ -18,7 +18,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", ] -dependencies = ["anyio>=4.5", "click>=8.1.0", "httpx>=0.27", "mcp"] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] [project.scripts] mcp-simple-prompt = "mcp_simple_prompt.server:main" @@ -40,8 +40,8 @@ select = ["E", "F", "I"] ignore = [] [tool.ruff] -line-length = 88 +line-length = 120 target-version = "py310" -[tool.uv] -dev-dependencies = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-resource/mcp_simple_resource/__init__.py b/examples/servers/simple-resource/mcp_simple_resource/__init__.py index 8b13789179..e69de29bb2 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/__init__.py +++ b/examples/servers/simple-resource/mcp_simple_resource/__init__.py @@ -1 +0,0 @@ - diff --git a/examples/servers/simple-resource/mcp_simple_resource/__main__.py b/examples/servers/simple-resource/mcp_simple_resource/__main__.py index 8b345fa2e5..e7ef16530b 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/__main__.py +++ b/examples/servers/simple-resource/mcp_simple_resource/__main__.py @@ -2,4 +2,4 @@ from .server import main -sys.exit(main()) +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py index 0ec1d926af..151a23eab4 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/server.py +++ b/examples/servers/simple-resource/mcp_simple_resource/server.py @@ -2,12 +2,23 @@ import click import mcp.types as types from mcp.server.lowlevel import Server -from pydantic import FileUrl +from mcp.server.lowlevel.helper_types import ReadResourceContents +from pydantic import AnyUrl, FileUrl +from starlette.requests import Request SAMPLE_RESOURCES = { - "greeting": "Hello! This is a sample text resource.", - "help": "This server provides a few sample text resources for testing.", - "about": "This is the simple-resource MCP server implementation.", + "greeting": { + "content": "Hello! This is a sample text resource.", + "title": "Welcome Message", + }, + "help": { + "content": "This server provides a few sample text resources for testing.", + "title": "Help Documentation", + }, + "about": { + "content": "This is the simple-resource MCP server implementation.", + "title": "About This Server", + }, } @@ -28,6 +39,7 @@ async def list_resources() -> list[types.Resource]: types.Resource( uri=FileUrl(f"file:///{name}.txt"), name=name, + title=SAMPLE_RESOURCES[name]["title"], description=f"A sample text resource named {name}", mimeType="text/plain", ) @@ -35,48 +47,46 @@ async def list_resources() -> list[types.Resource]: ] @app.read_resource() - async def read_resource(uri: FileUrl) -> str | bytes: + async def read_resource(uri: AnyUrl): + if uri.path is None: + raise ValueError(f"Invalid resource path: {uri}") name = uri.path.replace(".txt", "").lstrip("/") if name not in SAMPLE_RESOURCES: raise ValueError(f"Unknown resource: {uri}") - return SAMPLE_RESOURCES[name] + return [ReadResourceContents(content=SAMPLE_RESOURCES[name]["content"], mime_type="text/plain")] if transport == "sse": from mcp.server.sse import SseServerTransport from starlette.applications import Starlette + from starlette.responses import Response from starlette.routing import Mount, Route sse = SseServerTransport("/messages/") - async def handle_sse(request): - async with sse.connect_sse( - request.scope, request.receive, request._send - ) as streams: - await app.run( - streams[0], streams[1], app.create_initialization_options() - ) + async def handle_sse(request: Request): + async with sse.connect_sse(request.scope, request.receive, request._send) as streams: # type: ignore[reportPrivateUsage] + await app.run(streams[0], streams[1], app.create_initialization_options()) + return Response() starlette_app = Starlette( debug=True, routes=[ - Route("/sse", endpoint=handle_sse), + Route("/sse", endpoint=handle_sse, methods=["GET"]), Mount("/messages/", app=sse.handle_post_message), ], ) import uvicorn - uvicorn.run(starlette_app, host="0.0.0.0", port=port) + uvicorn.run(starlette_app, host="127.0.0.1", port=port) else: from mcp.server.stdio import stdio_server async def arun(): async with stdio_server() as streams: - await app.run( - streams[0], streams[1], app.create_initialization_options() - ) + await app.run(streams[0], streams[1], app.create_initialization_options()) anyio.run(arun) diff --git a/examples/servers/simple-resource/pyproject.toml b/examples/servers/simple-resource/pyproject.toml index cbab1ca478..14c2bd38cc 100644 --- a/examples/servers/simple-resource/pyproject.toml +++ b/examples/servers/simple-resource/pyproject.toml @@ -18,7 +18,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", ] -dependencies = ["anyio>=4.5", "click>=8.1.0", "httpx>=0.27", "mcp"] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] [project.scripts] mcp-simple-resource = "mcp_simple_resource.server:main" @@ -40,8 +40,8 @@ select = ["E", "F", "I"] ignore = [] [tool.ruff] -line-length = 88 +line-length = 120 target-version = "py310" -[tool.uv] -dev-dependencies = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-streamablehttp-stateless/README.md b/examples/servers/simple-streamablehttp-stateless/README.md new file mode 100644 index 0000000000..b87250b353 --- /dev/null +++ b/examples/servers/simple-streamablehttp-stateless/README.md @@ -0,0 +1,39 @@ +# MCP Simple StreamableHttp Stateless Server Example + +A stateless MCP server example demonstrating the StreamableHttp transport without maintaining session state. This example is ideal for understanding how to deploy MCP servers in multi-node environments where requests can be routed to any instance. + +## Features + +- Uses the StreamableHTTP transport in stateless mode (mcp_session_id=None) +- Each request creates a new ephemeral connection +- No session state maintained between requests +- Task lifecycle scoped to individual requests +- Suitable for deployment in multi-node environments + +## Usage + +Start the server: + +```bash +# Using default port 3000 +uv run mcp-simple-streamablehttp-stateless + +# Using custom port +uv run mcp-simple-streamablehttp-stateless --port 3000 + +# Custom logging level +uv run mcp-simple-streamablehttp-stateless --log-level DEBUG + +# Enable JSON responses instead of SSE streams +uv run mcp-simple-streamablehttp-stateless --json-response +``` + +The server exposes a tool named "start-notification-stream" that accepts three arguments: + +- `interval`: Time between notifications in seconds (e.g., 1.0) +- `count`: Number of notifications to send (e.g., 5) +- `caller`: Identifier string for the caller + +## Client + +You can connect to this server using an HTTP client. For now, only the TypeScript SDK has streamable HTTP client examples, or you can use [Inspector](https://github.com/modelcontextprotocol/inspector) for testing. diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__init__.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py new file mode 100644 index 0000000000..1664737e3a --- /dev/null +++ b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py @@ -0,0 +1,7 @@ +from .server import main + +if __name__ == "__main__": + # Click will handle CLI arguments + import sys + + sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py new file mode 100644 index 0000000000..f1b3987d28 --- /dev/null +++ b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py @@ -0,0 +1,140 @@ +import contextlib +import logging +from collections.abc import AsyncIterator +from typing import Any + +import anyio +import click +import mcp.types as types +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware +from starlette.routing import Mount +from starlette.types import Receive, Scope, Send + +logger = logging.getLogger(__name__) + + +@click.command() +@click.option("--port", default=3000, help="Port to listen on for HTTP") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", +) +@click.option( + "--json-response", + is_flag=True, + default=False, + help="Enable JSON responses instead of SSE streams", +) +def main( + port: int, + log_level: str, + json_response: bool, +) -> int: + # Configure logging + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + app = Server("mcp-streamable-http-stateless-demo") + + @app.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: + ctx = app.request_context + interval = arguments.get("interval", 1.0) + count = arguments.get("count", 5) + caller = arguments.get("caller", "unknown") + + # Send the specified number of notifications with the given interval + for i in range(count): + await ctx.session.send_log_message( + level="info", + data=f"Notification {i + 1}/{count} from caller: {caller}", + logger="notification_stream", + related_request_id=ctx.request_id, + ) + if i < count - 1: # Don't wait after the last notification + await anyio.sleep(interval) + + return [ + types.TextContent( + type="text", + text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"), + ) + ] + + @app.list_tools() + async def list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="start-notification-stream", + description=("Sends a stream of notifications with configurable count and interval"), + inputSchema={ + "type": "object", + "required": ["interval", "count", "caller"], + "properties": { + "interval": { + "type": "number", + "description": "Interval between notifications in seconds", + }, + "count": { + "type": "number", + "description": "Number of notifications to send", + }, + "caller": { + "type": "string", + "description": ("Identifier of the caller to include in notifications"), + }, + }, + }, + ) + ] + + # Create the session manager with true stateless mode + session_manager = StreamableHTTPSessionManager( + app=app, + event_store=None, + json_response=json_response, + stateless=True, + ) + + async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None: + await session_manager.handle_request(scope, receive, send) + + @contextlib.asynccontextmanager + async def lifespan(app: Starlette) -> AsyncIterator[None]: + """Context manager for session manager.""" + async with session_manager.run(): + logger.info("Application started with StreamableHTTP session manager!") + try: + yield + finally: + logger.info("Application shutting down...") + + # Create an ASGI application using the transport + starlette_app = Starlette( + debug=True, + routes=[ + Mount("/mcp", app=handle_streamable_http), + ], + lifespan=lifespan, + ) + + # Wrap ASGI application with CORS middleware to expose Mcp-Session-Id header + # for browser-based clients (ensures 500 errors get proper CORS headers) + starlette_app = CORSMiddleware( + starlette_app, + allow_origins=["*"], # Allow all origins - adjust as needed for production + allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods + expose_headers=["Mcp-Session-Id"], + ) + + import uvicorn + + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + + return 0 diff --git a/examples/servers/simple-streamablehttp-stateless/pyproject.toml b/examples/servers/simple-streamablehttp-stateless/pyproject.toml new file mode 100644 index 0000000000..0e695695cb --- /dev/null +++ b/examples/servers/simple-streamablehttp-stateless/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "mcp-simple-streamablehttp-stateless" +version = "0.1.0" +description = "A simple MCP server exposing a StreamableHttp transport in stateless mode" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic, PBC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable", "stateless"] +license = { text = "MIT" } +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-simple-streamablehttp-stateless = "mcp_simple_streamablehttp_stateless.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_streamablehttp_stateless"] + +[tool.pyright] +include = ["mcp_simple_streamablehttp_stateless"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-streamablehttp/README.md b/examples/servers/simple-streamablehttp/README.md new file mode 100644 index 0000000000..9836367170 --- /dev/null +++ b/examples/servers/simple-streamablehttp/README.md @@ -0,0 +1,53 @@ +# MCP Simple StreamableHttp Server Example + +A simple MCP server example demonstrating the StreamableHttp transport, which enables HTTP-based communication with MCP servers using streaming. + +## Features + +- Uses the StreamableHTTP transport for server-client communication +- Supports REST API operations (POST, GET, DELETE) for `/mcp` endpoint +- Task management with anyio task groups +- Ability to send multiple notifications over time to the client +- Proper resource cleanup and lifespan management +- Resumability support via InMemoryEventStore + +## Usage + +Start the server on the default or custom port: + +```bash + +# Using custom port +uv run mcp-simple-streamablehttp --port 3000 + +# Custom logging level +uv run mcp-simple-streamablehttp --log-level DEBUG + +# Enable JSON responses instead of SSE streams +uv run mcp-simple-streamablehttp --json-response +``` + +The server exposes a tool named "start-notification-stream" that accepts three arguments: + +- `interval`: Time between notifications in seconds (e.g., 1.0) +- `count`: Number of notifications to send (e.g., 5) +- `caller`: Identifier string for the caller + +## Resumability Support + +This server includes resumability support through the InMemoryEventStore. This enables clients to: + +- Reconnect to the server after a disconnection +- Resume event streaming from where they left off using the Last-Event-ID header + +The server will: + +- Generate unique event IDs for each SSE message +- Store events in memory for later replay +- Replay missed events when a client reconnects with a Last-Event-ID header + +Note: The InMemoryEventStore is designed for demonstration purposes only. For production use, consider implementing a persistent storage solution. + +## Client + +You can connect to this server using an HTTP client, for now only Typescript SDK has streamable HTTP client examples or you can use [Inspector](https://github.com/modelcontextprotocol/inspector) diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__init__.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py new file mode 100644 index 0000000000..21862e45fb --- /dev/null +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py @@ -0,0 +1,4 @@ +from .server import main + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py new file mode 100644 index 0000000000..0c3081ed64 --- /dev/null +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py @@ -0,0 +1,97 @@ +""" +In-memory event store for demonstrating resumability functionality. + +This is a simple implementation intended for examples and testing, +not for production use where a persistent storage solution would be more appropriate. +""" + +import logging +from collections import deque +from dataclasses import dataclass +from uuid import uuid4 + +from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId +from mcp.types import JSONRPCMessage + +logger = logging.getLogger(__name__) + + +@dataclass +class EventEntry: + """ + Represents an event entry in the event store. + """ + + event_id: EventId + stream_id: StreamId + message: JSONRPCMessage | None + + +class InMemoryEventStore(EventStore): + """ + Simple in-memory implementation of the EventStore interface for resumability. + This is primarily intended for examples and testing, not for production use + where a persistent storage solution would be more appropriate. + + This implementation keeps only the last N events per stream for memory efficiency. + """ + + def __init__(self, max_events_per_stream: int = 100): + """Initialize the event store. + + Args: + max_events_per_stream: Maximum number of events to keep per stream + """ + self.max_events_per_stream = max_events_per_stream + # for maintaining last N events per stream + self.streams: dict[StreamId, deque[EventEntry]] = {} + # event_id -> EventEntry for quick lookup + self.event_index: dict[EventId, EventEntry] = {} + + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + """Stores an event with a generated event ID.""" + event_id = str(uuid4()) + event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message) + + # Get or create deque for this stream + if stream_id not in self.streams: + self.streams[stream_id] = deque(maxlen=self.max_events_per_stream) + + # If deque is full, the oldest event will be automatically removed + # We need to remove it from the event_index as well + if len(self.streams[stream_id]) == self.max_events_per_stream: + oldest_event = self.streams[stream_id][0] + self.event_index.pop(oldest_event.event_id, None) + + # Add new event + self.streams[stream_id].append(event_entry) + self.event_index[event_id] = event_entry + + return event_id + + async def replay_events_after( + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + """Replays events that occurred after the specified event ID.""" + if last_event_id not in self.event_index: + logger.warning(f"Event ID {last_event_id} not found in store") + return None + + # Get the stream and find events after the last one + last_event = self.event_index[last_event_id] + stream_id = last_event.stream_id + stream_events = self.streams.get(last_event.stream_id, deque()) + + # Events in deque are already in chronological order + found_last = False + for event in stream_events: + if found_last: + # Skip priming events (None message) + if event.message is not None: + await send_callback(EventMessage(event.message, event.event_id)) + elif event.event_id == last_event_id: + found_last = True + + return stream_id diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py new file mode 100644 index 0000000000..4b2604b9af --- /dev/null +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py @@ -0,0 +1,165 @@ +import contextlib +import logging +from collections.abc import AsyncIterator +from typing import Any + +import anyio +import click +import mcp.types as types +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from pydantic import AnyUrl +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware +from starlette.routing import Mount +from starlette.types import Receive, Scope, Send + +from .event_store import InMemoryEventStore + +# Configure logging +logger = logging.getLogger(__name__) + + +@click.command() +@click.option("--port", default=3000, help="Port to listen on for HTTP") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", +) +@click.option( + "--json-response", + is_flag=True, + default=False, + help="Enable JSON responses instead of SSE streams", +) +def main( + port: int, + log_level: str, + json_response: bool, +) -> int: + # Configure logging + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + app = Server("mcp-streamable-http-demo") + + @app.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: + ctx = app.request_context + interval = arguments.get("interval", 1.0) + count = arguments.get("count", 5) + caller = arguments.get("caller", "unknown") + + # Send the specified number of notifications with the given interval + for i in range(count): + # Include more detailed message for resumability demonstration + notification_msg = f"[{i + 1}/{count}] Event from '{caller}' - Use Last-Event-ID to resume if disconnected" + await ctx.session.send_log_message( + level="info", + data=notification_msg, + logger="notification_stream", + # Associates this notification with the original request + # Ensures notifications are sent to the correct response stream + # Without this, notifications will either go to: + # - a standalone SSE stream (if GET request is supported) + # - nowhere (if GET request isn't supported) + related_request_id=ctx.request_id, + ) + logger.debug(f"Sent notification {i + 1}/{count} for caller: {caller}") + if i < count - 1: # Don't wait after the last notification + await anyio.sleep(interval) + + # This will send a resource notificaiton though standalone SSE + # established by GET request + await ctx.session.send_resource_updated(uri=AnyUrl("http:///test_resource")) + return [ + types.TextContent( + type="text", + text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"), + ) + ] + + @app.list_tools() + async def list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="start-notification-stream", + description=("Sends a stream of notifications with configurable count and interval"), + inputSchema={ + "type": "object", + "required": ["interval", "count", "caller"], + "properties": { + "interval": { + "type": "number", + "description": "Interval between notifications in seconds", + }, + "count": { + "type": "number", + "description": "Number of notifications to send", + }, + "caller": { + "type": "string", + "description": ("Identifier of the caller to include in notifications"), + }, + }, + }, + ) + ] + + # Create event store for resumability + # The InMemoryEventStore enables resumability support for StreamableHTTP transport. + # It stores SSE events with unique IDs, allowing clients to: + # 1. Receive event IDs for each SSE message + # 2. Resume streams by sending Last-Event-ID in GET requests + # 3. Replay missed events after reconnection + # Note: This in-memory implementation is for demonstration ONLY. + # For production, use a persistent storage solution. + event_store = InMemoryEventStore() + + # Create the session manager with our app and event store + session_manager = StreamableHTTPSessionManager( + app=app, + event_store=event_store, # Enable resumability + json_response=json_response, + ) + + # ASGI handler for streamable HTTP connections + async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None: + await session_manager.handle_request(scope, receive, send) + + @contextlib.asynccontextmanager + async def lifespan(app: Starlette) -> AsyncIterator[None]: + """Context manager for managing session manager lifecycle.""" + async with session_manager.run(): + logger.info("Application started with StreamableHTTP session manager!") + try: + yield + finally: + logger.info("Application shutting down...") + + # Create an ASGI application using the transport + starlette_app = Starlette( + debug=True, + routes=[ + Mount("/mcp", app=handle_streamable_http), + ], + lifespan=lifespan, + ) + + # Wrap ASGI application with CORS middleware to expose Mcp-Session-Id header + # for browser-based clients (ensures 500 errors get proper CORS headers) + starlette_app = CORSMiddleware( + starlette_app, + allow_origins=["*"], # Allow all origins - adjust as needed for production + allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods + expose_headers=["Mcp-Session-Id"], + ) + + import uvicorn + + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + + return 0 diff --git a/examples/servers/simple-streamablehttp/pyproject.toml b/examples/servers/simple-streamablehttp/pyproject.toml new file mode 100644 index 0000000000..f0404fb7dd --- /dev/null +++ b/examples/servers/simple-streamablehttp/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "mcp-simple-streamablehttp" +version = "0.1.0" +description = "A simple MCP server exposing a StreamableHttp transport for testing" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic, PBC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable"] +license = { text = "MIT" } +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-simple-streamablehttp = "mcp_simple_streamablehttp.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_streamablehttp"] + +[tool.pyright] +include = ["mcp_simple_streamablehttp"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-task-interactive/README.md b/examples/servers/simple-task-interactive/README.md new file mode 100644 index 0000000000..b8f384cb48 --- /dev/null +++ b/examples/servers/simple-task-interactive/README.md @@ -0,0 +1,74 @@ +# Simple Interactive Task Server + +A minimal MCP server demonstrating interactive tasks with elicitation and sampling. + +## Running + +```bash +cd examples/servers/simple-task-interactive +uv run mcp-simple-task-interactive +``` + +The server starts on `http://localhost:8000/mcp` by default. Use `--port` to change. + +## What it does + +This server exposes two tools: + +### `confirm_delete` (demonstrates elicitation) + +Asks the user for confirmation before "deleting" a file. + +- Uses `task.elicit()` to request user input +- Shows the elicitation flow: task -> input_required -> response -> complete + +### `write_haiku` (demonstrates sampling) + +Asks the LLM to write a haiku about a topic. + +- Uses `task.create_message()` to request LLM completion +- Shows the sampling flow: task -> input_required -> response -> complete + +## Usage with the client + +In one terminal, start the server: + +```bash +cd examples/servers/simple-task-interactive +uv run mcp-simple-task-interactive +``` + +In another terminal, run the interactive client: + +```bash +cd examples/clients/simple-task-interactive-client +uv run mcp-simple-task-interactive-client +``` + +## Expected server output + +When a client connects and calls the tools, you'll see: + +```text +Starting server on http://localhost:8000/mcp + +[Server] confirm_delete called for 'important.txt' +[Server] Task created: +[Server] Sending elicitation request to client... +[Server] Received elicitation response: action=accept, content={'confirm': True} +[Server] Completing task with result: Deleted 'important.txt' + +[Server] write_haiku called for topic 'autumn leaves' +[Server] Task created: +[Server] Sending sampling request to client... +[Server] Received sampling response: Cherry blossoms fall +Softly on the quiet pon... +[Server] Completing task with haiku +``` + +## Key concepts + +1. **ServerTaskContext**: Provides `elicit()` and `create_message()` for user interaction +2. **run_task()**: Spawns background work, auto-completes/fails, returns immediately +3. **TaskResultHandler**: Delivers queued messages and routes responses +4. **Response routing**: Responses are routed back to waiting resolvers diff --git a/examples/servers/simple-task-interactive/mcp_simple_task_interactive/__init__.py b/examples/servers/simple-task-interactive/mcp_simple_task_interactive/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/servers/simple-task-interactive/mcp_simple_task_interactive/__main__.py b/examples/servers/simple-task-interactive/mcp_simple_task_interactive/__main__.py new file mode 100644 index 0000000000..e7ef16530b --- /dev/null +++ b/examples/servers/simple-task-interactive/mcp_simple_task_interactive/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-task-interactive/mcp_simple_task_interactive/server.py b/examples/servers/simple-task-interactive/mcp_simple_task_interactive/server.py new file mode 100644 index 0000000000..4d35ca8094 --- /dev/null +++ b/examples/servers/simple-task-interactive/mcp_simple_task_interactive/server.py @@ -0,0 +1,147 @@ +"""Simple interactive task server demonstrating elicitation and sampling. + +This example shows the simplified task API where: +- server.experimental.enable_tasks() sets up all infrastructure +- ctx.experimental.run_task() handles task lifecycle automatically +- ServerTaskContext.elicit() and ServerTaskContext.create_message() queue requests properly +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +import click +import mcp.types as types +import uvicorn +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.applications import Starlette +from starlette.routing import Mount + +server = Server("simple-task-interactive") + +# Enable task support - this auto-registers all handlers +server.experimental.enable_tasks() + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="confirm_delete", + description="Asks for confirmation before deleting (demonstrates elicitation)", + inputSchema={ + "type": "object", + "properties": {"filename": {"type": "string"}}, + }, + execution=types.ToolExecution(taskSupport=types.TASK_REQUIRED), + ), + types.Tool( + name="write_haiku", + description="Asks LLM to write a haiku (demonstrates sampling)", + inputSchema={"type": "object", "properties": {"topic": {"type": "string"}}}, + execution=types.ToolExecution(taskSupport=types.TASK_REQUIRED), + ), + ] + + +async def handle_confirm_delete(arguments: dict[str, Any]) -> types.CreateTaskResult: + """Handle the confirm_delete tool - demonstrates elicitation.""" + ctx = server.request_context + ctx.experimental.validate_task_mode(types.TASK_REQUIRED) + + filename = arguments.get("filename", "unknown.txt") + print(f"\n[Server] confirm_delete called for '{filename}'") + + async def work(task: ServerTaskContext) -> types.CallToolResult: + print(f"[Server] Task {task.task_id} starting elicitation...") + + result = await task.elicit( + message=f"Are you sure you want to delete '{filename}'?", + requestedSchema={ + "type": "object", + "properties": {"confirm": {"type": "boolean"}}, + "required": ["confirm"], + }, + ) + + print(f"[Server] Received elicitation response: action={result.action}, content={result.content}") + + if result.action == "accept" and result.content: + confirmed = result.content.get("confirm", False) + text = f"Deleted '{filename}'" if confirmed else "Deletion cancelled" + else: + text = "Deletion cancelled" + + print(f"[Server] Completing task with result: {text}") + return types.CallToolResult(content=[types.TextContent(type="text", text=text)]) + + return await ctx.experimental.run_task(work) + + +async def handle_write_haiku(arguments: dict[str, Any]) -> types.CreateTaskResult: + """Handle the write_haiku tool - demonstrates sampling.""" + ctx = server.request_context + ctx.experimental.validate_task_mode(types.TASK_REQUIRED) + + topic = arguments.get("topic", "nature") + print(f"\n[Server] write_haiku called for topic '{topic}'") + + async def work(task: ServerTaskContext) -> types.CallToolResult: + print(f"[Server] Task {task.task_id} starting sampling...") + + result = await task.create_message( + messages=[ + types.SamplingMessage( + role="user", + content=types.TextContent(type="text", text=f"Write a haiku about {topic}"), + ) + ], + max_tokens=50, + ) + + haiku = "No response" + if isinstance(result.content, types.TextContent): + haiku = result.content.text + + print(f"[Server] Received sampling response: {haiku[:50]}...") + return types.CallToolResult(content=[types.TextContent(type="text", text=f"Haiku:\n{haiku}")]) + + return await ctx.experimental.run_task(work) + + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult | types.CreateTaskResult: + """Dispatch tool calls to their handlers.""" + if name == "confirm_delete": + return await handle_confirm_delete(arguments) + elif name == "write_haiku": + return await handle_write_haiku(arguments) + else: + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Unknown tool: {name}")], + isError=True, + ) + + +def create_app(session_manager: StreamableHTTPSessionManager) -> Starlette: + @asynccontextmanager + async def app_lifespan(app: Starlette) -> AsyncIterator[None]: + async with session_manager.run(): + yield + + return Starlette( + routes=[Mount("/mcp", app=session_manager.handle_request)], + lifespan=app_lifespan, + ) + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on") +def main(port: int) -> int: + session_manager = StreamableHTTPSessionManager(app=server) + starlette_app = create_app(session_manager) + print(f"Starting server on http://localhost:{port}/mcp") + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + return 0 diff --git a/examples/servers/simple-task-interactive/pyproject.toml b/examples/servers/simple-task-interactive/pyproject.toml new file mode 100644 index 0000000000..492345ff52 --- /dev/null +++ b/examples/servers/simple-task-interactive/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-task-interactive" +version = "0.1.0" +description = "A simple MCP server demonstrating interactive tasks (elicitation & sampling)" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic, PBC." }] +keywords = ["mcp", "llm", "tasks", "elicitation", "sampling"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.0", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-simple-task-interactive = "mcp_simple_task_interactive.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_task_interactive"] + +[tool.pyright] +include = ["mcp_simple_task_interactive"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "ruff>=0.6.9"] diff --git a/examples/servers/simple-task/README.md b/examples/servers/simple-task/README.md new file mode 100644 index 0000000000..6914e0414f --- /dev/null +++ b/examples/servers/simple-task/README.md @@ -0,0 +1,37 @@ +# Simple Task Server + +A minimal MCP server demonstrating the experimental tasks feature over streamable HTTP. + +## Running + +```bash +cd examples/servers/simple-task +uv run mcp-simple-task +``` + +The server starts on `http://localhost:8000/mcp` by default. Use `--port` to change. + +## What it does + +This server exposes a single tool `long_running_task` that: + +1. Must be called as a task (with `task` metadata in the request) +2. Takes ~3 seconds to complete +3. Sends status updates during execution +4. Returns a result when complete + +## Usage with the client + +In one terminal, start the server: + +```bash +cd examples/servers/simple-task +uv run mcp-simple-task +``` + +In another terminal, run the client: + +```bash +cd examples/clients/simple-task-client +uv run mcp-simple-task-client +``` diff --git a/examples/servers/simple-task/mcp_simple_task/__init__.py b/examples/servers/simple-task/mcp_simple_task/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/servers/simple-task/mcp_simple_task/__main__.py b/examples/servers/simple-task/mcp_simple_task/__main__.py new file mode 100644 index 0000000000..e7ef16530b --- /dev/null +++ b/examples/servers/simple-task/mcp_simple_task/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-task/mcp_simple_task/server.py b/examples/servers/simple-task/mcp_simple_task/server.py new file mode 100644 index 0000000000..d0681b8423 --- /dev/null +++ b/examples/servers/simple-task/mcp_simple_task/server.py @@ -0,0 +1,84 @@ +"""Simple task server demonstrating MCP tasks over streamable HTTP.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +import anyio +import click +import mcp.types as types +import uvicorn +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.applications import Starlette +from starlette.routing import Mount + +server = Server("simple-task-server") + +# One-line setup: auto-registers get_task, get_task_result, list_tasks, cancel_task +server.experimental.enable_tasks() + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="long_running_task", + description="A task that takes a few seconds to complete with status updates", + inputSchema={"type": "object", "properties": {}}, + execution=types.ToolExecution(taskSupport=types.TASK_REQUIRED), + ) + ] + + +async def handle_long_running_task(arguments: dict[str, Any]) -> types.CreateTaskResult: + """Handle the long_running_task tool - demonstrates status updates.""" + ctx = server.request_context + ctx.experimental.validate_task_mode(types.TASK_REQUIRED) + + async def work(task: ServerTaskContext) -> types.CallToolResult: + await task.update_status("Starting work...") + await anyio.sleep(1) + + await task.update_status("Processing step 1...") + await anyio.sleep(1) + + await task.update_status("Processing step 2...") + await anyio.sleep(1) + + return types.CallToolResult(content=[types.TextContent(type="text", text="Task completed!")]) + + return await ctx.experimental.run_task(work) + + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult | types.CreateTaskResult: + """Dispatch tool calls to their handlers.""" + if name == "long_running_task": + return await handle_long_running_task(arguments) + else: + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Unknown tool: {name}")], + isError=True, + ) + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on") +def main(port: int) -> int: + session_manager = StreamableHTTPSessionManager(app=server) + + @asynccontextmanager + async def app_lifespan(app: Starlette) -> AsyncIterator[None]: + async with session_manager.run(): + yield + + starlette_app = Starlette( + routes=[Mount("/mcp", app=session_manager.handle_request)], + lifespan=app_lifespan, + ) + + print(f"Starting server on http://localhost:{port}/mcp") + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + return 0 diff --git a/examples/servers/simple-task/pyproject.toml b/examples/servers/simple-task/pyproject.toml new file mode 100644 index 0000000000..a8fba8bdc1 --- /dev/null +++ b/examples/servers/simple-task/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-task" +version = "0.1.0" +description = "A simple MCP server demonstrating tasks" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic, PBC." }] +keywords = ["mcp", "llm", "tasks"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.0", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-simple-task = "mcp_simple_task.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_task"] + +[tool.pyright] +include = ["mcp_simple_task"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "ruff>=0.6.9"] diff --git a/examples/servers/simple-tool/mcp_simple_tool/__init__.py b/examples/servers/simple-tool/mcp_simple_tool/__init__.py index 8b13789179..e69de29bb2 100644 --- a/examples/servers/simple-tool/mcp_simple_tool/__init__.py +++ b/examples/servers/simple-tool/mcp_simple_tool/__init__.py @@ -1 +0,0 @@ - diff --git a/examples/servers/simple-tool/mcp_simple_tool/__main__.py b/examples/servers/simple-tool/mcp_simple_tool/__main__.py index 8b345fa2e5..e7ef16530b 100644 --- a/examples/servers/simple-tool/mcp_simple_tool/__main__.py +++ b/examples/servers/simple-tool/mcp_simple_tool/__main__.py @@ -2,4 +2,4 @@ from .server import main -sys.exit(main()) +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-tool/mcp_simple_tool/server.py b/examples/servers/simple-tool/mcp_simple_tool/server.py index 3eace52eaf..5b2b7d068d 100644 --- a/examples/servers/simple-tool/mcp_simple_tool/server.py +++ b/examples/servers/simple-tool/mcp_simple_tool/server.py @@ -1,17 +1,18 @@ +from typing import Any + import anyio import click -import httpx import mcp.types as types from mcp.server.lowlevel import Server +from mcp.shared._httpx_utils import create_mcp_http_client +from starlette.requests import Request async def fetch_website( url: str, -) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: - headers = { - "User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)" - } - async with httpx.AsyncClient(follow_redirects=True, headers=headers) as client: +) -> list[types.ContentBlock]: + headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"} + async with create_mcp_http_client(headers=headers) as client: response = await client.get(url) response.raise_for_status() return [types.TextContent(type="text", text=response.text)] @@ -29,9 +30,7 @@ def main(port: int, transport: str) -> int: app = Server("mcp-website-fetcher") @app.call_tool() - async def fetch_tool( - name: str, arguments: dict - ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: + async def fetch_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: if name != "fetch": raise ValueError(f"Unknown tool: {name}") if "url" not in arguments: @@ -43,6 +42,7 @@ async def list_tools() -> list[types.Tool]: return [ types.Tool( name="fetch", + title="Website Fetcher", description="Fetches a website and returns its content", inputSchema={ "type": "object", @@ -60,37 +60,33 @@ async def list_tools() -> list[types.Tool]: if transport == "sse": from mcp.server.sse import SseServerTransport from starlette.applications import Starlette + from starlette.responses import Response from starlette.routing import Mount, Route sse = SseServerTransport("/messages/") - async def handle_sse(request): - async with sse.connect_sse( - request.scope, request.receive, request._send - ) as streams: - await app.run( - streams[0], streams[1], app.create_initialization_options() - ) + async def handle_sse(request: Request): + async with sse.connect_sse(request.scope, request.receive, request._send) as streams: # type: ignore[reportPrivateUsage] + await app.run(streams[0], streams[1], app.create_initialization_options()) + return Response() starlette_app = Starlette( debug=True, routes=[ - Route("/sse", endpoint=handle_sse), + Route("/sse", endpoint=handle_sse, methods=["GET"]), Mount("/messages/", app=sse.handle_post_message), ], ) import uvicorn - uvicorn.run(starlette_app, host="0.0.0.0", port=port) + uvicorn.run(starlette_app, host="127.0.0.1", port=port) else: from mcp.server.stdio import stdio_server async def arun(): async with stdio_server() as streams: - await app.run( - streams[0], streams[1], app.create_initialization_options() - ) + await app.run(streams[0], streams[1], app.create_initialization_options()) anyio.run(arun) diff --git a/examples/servers/simple-tool/pyproject.toml b/examples/servers/simple-tool/pyproject.toml index c690aad97a..c3944f3146 100644 --- a/examples/servers/simple-tool/pyproject.toml +++ b/examples/servers/simple-tool/pyproject.toml @@ -18,7 +18,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", ] -dependencies = ["anyio>=4.5", "click>=8.1.0", "httpx>=0.27", "mcp"] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] [project.scripts] mcp-simple-tool = "mcp_simple_tool.server:main" @@ -40,8 +40,8 @@ select = ["E", "F", "I"] ignore = [] [tool.ruff] -line-length = 88 +line-length = 120 target-version = "py310" -[tool.uv] -dev-dependencies = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/sse-polling-demo/README.md b/examples/servers/sse-polling-demo/README.md new file mode 100644 index 0000000000..e9d4446e1f --- /dev/null +++ b/examples/servers/sse-polling-demo/README.md @@ -0,0 +1,36 @@ +# MCP SSE Polling Demo Server + +Demonstrates the SSE polling pattern with server-initiated stream close for long-running tasks (SEP-1699). + +## Features + +- Priming events (automatic with EventStore) +- Server-initiated stream close via `close_sse_stream()` callback +- Client auto-reconnect with Last-Event-ID +- Progress notifications during long-running tasks +- Configurable retry interval + +## Usage + +```bash +# Start server on default port +uv run mcp-sse-polling-demo --port 3000 + +# Custom retry interval (milliseconds) +uv run mcp-sse-polling-demo --port 3000 --retry-interval 100 +``` + +## Tool: process_batch + +Processes items with periodic checkpoints that trigger SSE stream closes: + +- `items`: Number of items to process (1-100, default: 10) +- `checkpoint_every`: Close stream after this many items (1-20, default: 3) + +## Client + +Use the companion `mcp-sse-polling-client` to test: + +```bash +uv run mcp-sse-polling-client --url http://localhost:3000/mcp +``` diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py new file mode 100644 index 0000000000..46af2fdeed --- /dev/null +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py @@ -0,0 +1 @@ +"""SSE Polling Demo Server - demonstrates close_sse_stream for long-running tasks.""" diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py new file mode 100644 index 0000000000..23cfc85e11 --- /dev/null +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for the SSE Polling Demo server.""" + +from .server import main + +if __name__ == "__main__": + main() diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py new file mode 100644 index 0000000000..75f98cdd49 --- /dev/null +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py @@ -0,0 +1,100 @@ +""" +In-memory event store for demonstrating resumability functionality. + +This is a simple implementation intended for examples and testing, +not for production use where a persistent storage solution would be more appropriate. +""" + +import logging +from collections import deque +from dataclasses import dataclass +from uuid import uuid4 + +from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId +from mcp.types import JSONRPCMessage + +logger = logging.getLogger(__name__) + + +@dataclass +class EventEntry: + """Represents an event entry in the event store.""" + + event_id: EventId + stream_id: StreamId + message: JSONRPCMessage | None # None for priming events + + +class InMemoryEventStore(EventStore): + """ + Simple in-memory implementation of the EventStore interface for resumability. + This is primarily intended for examples and testing, not for production use + where a persistent storage solution would be more appropriate. + + This implementation keeps only the last N events per stream for memory efficiency. + """ + + def __init__(self, max_events_per_stream: int = 100): + """Initialize the event store. + + Args: + max_events_per_stream: Maximum number of events to keep per stream + """ + self.max_events_per_stream = max_events_per_stream + # for maintaining last N events per stream + self.streams: dict[StreamId, deque[EventEntry]] = {} + # event_id -> EventEntry for quick lookup + self.event_index: dict[EventId, EventEntry] = {} + + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + """Stores an event with a generated event ID. + + Args: + stream_id: ID of the stream the event belongs to + message: The message to store, or None for priming events + """ + event_id = str(uuid4()) + event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message) + + # Get or create deque for this stream + if stream_id not in self.streams: + self.streams[stream_id] = deque(maxlen=self.max_events_per_stream) + + # If deque is full, the oldest event will be automatically removed + # We need to remove it from the event_index as well + if len(self.streams[stream_id]) == self.max_events_per_stream: + oldest_event = self.streams[stream_id][0] + self.event_index.pop(oldest_event.event_id, None) + + # Add new event + self.streams[stream_id].append(event_entry) + self.event_index[event_id] = event_entry + + return event_id + + async def replay_events_after( + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + """Replays events that occurred after the specified event ID.""" + if last_event_id not in self.event_index: + logger.warning(f"Event ID {last_event_id} not found in store") + return None + + # Get the stream and find events after the last one + last_event = self.event_index[last_event_id] + stream_id = last_event.stream_id + stream_events = self.streams.get(last_event.stream_id, deque()) + + # Events in deque are already in chronological order + found_last = False + for event in stream_events: + if found_last: + # Skip priming events (None messages) during replay + if event.message is not None: + await send_callback(EventMessage(event.message, event.event_id)) + elif event.event_id == last_event_id: + found_last = True + + return stream_id diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py new file mode 100644 index 0000000000..e4bdcaa396 --- /dev/null +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py @@ -0,0 +1,177 @@ +""" +SSE Polling Demo Server + +Demonstrates the SSE polling pattern with close_sse_stream() for long-running tasks. + +Features demonstrated: +- Priming events (automatic with EventStore) +- Server-initiated stream close via close_sse_stream callback +- Client auto-reconnect with Last-Event-ID +- Progress notifications during long-running tasks + +Run with: + uv run mcp-sse-polling-demo --port 3000 +""" + +import contextlib +import logging +from collections.abc import AsyncIterator +from typing import Any + +import anyio +import click +import mcp.types as types +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.applications import Starlette +from starlette.routing import Mount +from starlette.types import Receive, Scope, Send + +from .event_store import InMemoryEventStore + +logger = logging.getLogger(__name__) + + +@click.command() +@click.option("--port", default=3000, help="Port to listen on") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR)", +) +@click.option( + "--retry-interval", + default=100, + help="SSE retry interval in milliseconds (sent to client)", +) +def main(port: int, log_level: str, retry_interval: int) -> int: + """Run the SSE Polling Demo server.""" + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + # Create the lowlevel server + app = Server("sse-polling-demo") + + @app.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: + """Handle tool calls.""" + ctx = app.request_context + + if name == "process_batch": + items = arguments.get("items", 10) + checkpoint_every = arguments.get("checkpoint_every", 3) + + if items < 1 or items > 100: + return [types.TextContent(type="text", text="Error: items must be between 1 and 100")] + if checkpoint_every < 1 or checkpoint_every > 20: + return [types.TextContent(type="text", text="Error: checkpoint_every must be between 1 and 20")] + + await ctx.session.send_log_message( + level="info", + data=f"Starting batch processing of {items} items...", + logger="process_batch", + related_request_id=ctx.request_id, + ) + + for i in range(1, items + 1): + # Simulate work + await anyio.sleep(0.5) + + # Report progress + await ctx.session.send_log_message( + level="info", + data=f"[{i}/{items}] Processing item {i}", + logger="process_batch", + related_request_id=ctx.request_id, + ) + + # Checkpoint: close stream to trigger client reconnect + if i % checkpoint_every == 0 and i < items: + await ctx.session.send_log_message( + level="info", + data=f"Checkpoint at item {i} - closing SSE stream for polling", + logger="process_batch", + related_request_id=ctx.request_id, + ) + if ctx.close_sse_stream: + logger.info(f"Closing SSE stream at checkpoint {i}") + await ctx.close_sse_stream() + # Wait for client to reconnect (must be > retry_interval of 100ms) + await anyio.sleep(0.2) + + return [ + types.TextContent( + type="text", + text=f"Successfully processed {items} items with checkpoints every {checkpoint_every} items", + ) + ] + + return [types.TextContent(type="text", text=f"Unknown tool: {name}")] + + @app.list_tools() + async def list_tools() -> list[types.Tool]: + """List available tools.""" + return [ + types.Tool( + name="process_batch", + description=( + "Process a batch of items with periodic checkpoints. " + "Demonstrates SSE polling where server closes stream periodically." + ), + inputSchema={ + "type": "object", + "properties": { + "items": { + "type": "integer", + "description": "Number of items to process (1-100)", + "default": 10, + }, + "checkpoint_every": { + "type": "integer", + "description": "Close stream after this many items (1-20)", + "default": 3, + }, + }, + }, + ) + ] + + # Create event store for resumability + event_store = InMemoryEventStore() + + # Create session manager with event store and retry interval + session_manager = StreamableHTTPSessionManager( + app=app, + event_store=event_store, + retry_interval=retry_interval, + ) + + async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None: + await session_manager.handle_request(scope, receive, send) + + @contextlib.asynccontextmanager + async def lifespan(starlette_app: Starlette) -> AsyncIterator[None]: + async with session_manager.run(): + logger.info(f"SSE Polling Demo server started on port {port}") + logger.info("Try: POST /mcp with tools/call for 'process_batch'") + yield + logger.info("Server shutting down...") + + starlette_app = Starlette( + debug=True, + routes=[ + Mount("/mcp", app=handle_streamable_http), + ], + lifespan=lifespan, + ) + + import uvicorn + + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + return 0 + + +if __name__ == "__main__": + main() diff --git a/examples/servers/sse-polling-demo/pyproject.toml b/examples/servers/sse-polling-demo/pyproject.toml new file mode 100644 index 0000000000..f7ad89217c --- /dev/null +++ b/examples/servers/sse-polling-demo/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "mcp-sse-polling-demo" +version = "0.1.0" +description = "Demo server showing SSE polling with close_sse_stream for long-running tasks" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Anthropic, PBC." }] +keywords = ["mcp", "sse", "polling", "streamable", "http"] +license = { text = "MIT" } +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-sse-polling-demo = "mcp_sse_polling_demo.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_sse_polling_demo"] + +[tool.pyright] +include = ["mcp_sse_polling_demo"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__init__.py b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__init__.py new file mode 100644 index 0000000000..c65905675b --- /dev/null +++ b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__init__.py @@ -0,0 +1 @@ +"""Example of structured output with low-level MCP server.""" diff --git a/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py new file mode 100644 index 0000000000..7f102ff8b5 --- /dev/null +++ b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +Example low-level MCP server demonstrating structured output support. + +This example shows how to use the low-level server API to return +structured data from tools, with automatic validation against output +schemas. +""" + +import asyncio +from datetime import datetime +from typing import Any + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +# Create low-level server instance +server = Server("structured-output-lowlevel-example") + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """List available tools with their schemas.""" + return [ + types.Tool( + name="get_weather", + description="Get weather information (simulated)", + inputSchema={ + "type": "object", + "properties": {"city": {"type": "string", "description": "City name"}}, + "required": ["city"], + }, + outputSchema={ + "type": "object", + "properties": { + "temperature": {"type": "number"}, + "conditions": {"type": "string"}, + "humidity": {"type": "integer", "minimum": 0, "maximum": 100}, + "wind_speed": {"type": "number"}, + "timestamp": {"type": "string", "format": "date-time"}, + }, + "required": ["temperature", "conditions", "humidity", "wind_speed", "timestamp"], + }, + ), + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> Any: + """ + Handle tool call with structured output. + """ + + if name == "get_weather": + # city = arguments["city"] # Would be used with real weather API + + # Simulate weather data (in production, call a real weather API) + import random + + weather_conditions = ["sunny", "cloudy", "rainy", "partly cloudy", "foggy"] + + weather_data = { + "temperature": round(random.uniform(0, 35), 1), + "conditions": random.choice(weather_conditions), + "humidity": random.randint(30, 90), + "wind_speed": round(random.uniform(0, 30), 1), + "timestamp": datetime.now().isoformat(), + } + + # Return structured data only + # The low-level server will serialize this to JSON content automatically + return weather_data + + else: + raise ValueError(f"Unknown tool: {name}") + + +async def run(): + """Run the low-level server using stdio transport.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="structured-output-lowlevel-example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/examples/servers/structured-output-lowlevel/pyproject.toml b/examples/servers/structured-output-lowlevel/pyproject.toml new file mode 100644 index 0000000000..554efc6145 --- /dev/null +++ b/examples/servers/structured-output-lowlevel/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "mcp-structured-output-lowlevel" +version = "0.1.0" +description = "Example of structured output with low-level MCP server" +requires-python = ">=3.10" +dependencies = ["mcp"] diff --git a/examples/snippets/clients/__init__.py b/examples/snippets/clients/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/snippets/clients/completion_client.py b/examples/snippets/clients/completion_client.py new file mode 100644 index 0000000000..8c5615926e --- /dev/null +++ b/examples/snippets/clients/completion_client.py @@ -0,0 +1,78 @@ +""" +cd to the `examples/snippets` directory and run: + uv run completion-client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.types import PromptReference, ResourceTemplateReference + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "completion", "stdio"], # Server with completion support + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +async def run(): + """Run the completion client example.""" + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + + # List available resource templates + templates = await session.list_resource_templates() + print("Available resource templates:") + for template in templates.resourceTemplates: + print(f" - {template.uriTemplate}") + + # List available prompts + prompts = await session.list_prompts() + print("\nAvailable prompts:") + for prompt in prompts.prompts: + print(f" - {prompt.name}") + + # Complete resource template arguments + if templates.resourceTemplates: + template = templates.resourceTemplates[0] + print(f"\nCompleting arguments for resource template: {template.uriTemplate}") + + # Complete without context + result = await session.complete( + ref=ResourceTemplateReference(type="ref/resource", uri=template.uriTemplate), + argument={"name": "owner", "value": "model"}, + ) + print(f"Completions for 'owner' starting with 'model': {result.completion.values}") + + # Complete with context - repo suggestions based on owner + result = await session.complete( + ref=ResourceTemplateReference(type="ref/resource", uri=template.uriTemplate), + argument={"name": "repo", "value": ""}, + context_arguments={"owner": "modelcontextprotocol"}, + ) + print(f"Completions for 'repo' with owner='modelcontextprotocol': {result.completion.values}") + + # Complete prompt arguments + if prompts.prompts: + prompt_name = prompts.prompts[0].name + print(f"\nCompleting arguments for prompt: {prompt_name}") + + result = await session.complete( + ref=PromptReference(type="ref/prompt", name=prompt_name), + argument={"name": "style", "value": ""}, + ) + print(f"Completions for 'style' argument: {result.completion.values}") + + +def main(): + """Entry point for the completion client.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/examples/snippets/clients/display_utilities.py b/examples/snippets/clients/display_utilities.py new file mode 100644 index 0000000000..5f1d50510d --- /dev/null +++ b/examples/snippets/clients/display_utilities.py @@ -0,0 +1,67 @@ +""" +cd to the `examples/snippets` directory and run: + uv run display-utilities-client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.shared.metadata_utils import get_display_name + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "fastmcp_quickstart", "stdio"], + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +async def display_tools(session: ClientSession): + """Display available tools with human-readable names""" + tools_response = await session.list_tools() + + for tool in tools_response.tools: + # get_display_name() returns the title if available, otherwise the name + display_name = get_display_name(tool) + print(f"Tool: {display_name}") + if tool.description: + print(f" {tool.description}") + + +async def display_resources(session: ClientSession): + """Display available resources with human-readable names""" + resources_response = await session.list_resources() + + for resource in resources_response.resources: + display_name = get_display_name(resource) + print(f"Resource: {display_name} ({resource.uri})") + + templates_response = await session.list_resource_templates() + for template in templates_response.resourceTemplates: + display_name = get_display_name(template) + print(f"Resource Template: {display_name}") + + +async def run(): + """Run the display utilities example.""" + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + + print("=== Available Tools ===") + await display_tools(session) + + print("\n=== Available Resources ===") + await display_resources(session) + + +def main(): + """Entry point for the display utilities client.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/examples/snippets/clients/oauth_client.py b/examples/snippets/clients/oauth_client.py new file mode 100644 index 0000000000..140b38aedb --- /dev/null +++ b/examples/snippets/clients/oauth_client.py @@ -0,0 +1,89 @@ +""" +Before running, specify running MCP RS server URL. +To spin up RS server locally, see + examples/servers/simple-auth/README.md + +cd to the `examples/snippets` directory and run: + uv run oauth-client +""" + +import asyncio +from urllib.parse import parse_qs, urlparse + +import httpx +from pydantic import AnyUrl + +from mcp import ClientSession +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class InMemoryTokenStorage(TokenStorage): + """Demo In-memory token storage implementation.""" + + def __init__(self): + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + """Get stored tokens.""" + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + """Store tokens.""" + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + """Get stored client information.""" + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + """Store client information.""" + self.client_info = client_info + + +async def handle_redirect(auth_url: str) -> None: + print(f"Visit: {auth_url}") + + +async def handle_callback() -> tuple[str, str | None]: + callback_url = input("Paste callback URL: ") + params = parse_qs(urlparse(callback_url).query) + return params["code"][0], params.get("state", [None])[0] + + +async def main(): + """Run the OAuth client example.""" + oauth_auth = OAuthClientProvider( + server_url="http://localhost:8001", + client_metadata=OAuthClientMetadata( + client_name="Example MCP Client", + redirect_uris=[AnyUrl("http://localhost:3000/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scope="user", + ), + storage=InMemoryTokenStorage(), + redirect_handler=handle_redirect, + callback_handler=handle_callback, + ) + + async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: + async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + resources = await session.list_resources() + print(f"Available resources: {[r.uri for r in resources.resources]}") + + +def run(): + asyncio.run(main()) + + +if __name__ == "__main__": + run() diff --git a/examples/snippets/clients/pagination_client.py b/examples/snippets/clients/pagination_client.py new file mode 100644 index 0000000000..1805d2d315 --- /dev/null +++ b/examples/snippets/clients/pagination_client.py @@ -0,0 +1,41 @@ +""" +Example of consuming paginated MCP endpoints from a client. +""" + +import asyncio + +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.types import PaginatedRequestParams, Resource + + +async def list_all_resources() -> None: + """Fetch all resources using pagination.""" + async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as ( + read, + write, + ): + async with ClientSession(read, write) as session: + await session.initialize() + + all_resources: list[Resource] = [] + cursor = None + + while True: + # Fetch a page of resources + result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor)) + all_resources.extend(result.resources) + + print(f"Fetched {len(result.resources)} resources") + + # Check if there are more pages + if result.nextCursor: + cursor = result.nextCursor + else: + break + + print(f"Total resources: {len(all_resources)}") + + +if __name__ == "__main__": + asyncio.run(list_all_resources()) diff --git a/examples/snippets/clients/parsing_tool_results.py b/examples/snippets/clients/parsing_tool_results.py new file mode 100644 index 0000000000..5158735461 --- /dev/null +++ b/examples/snippets/clients/parsing_tool_results.py @@ -0,0 +1,60 @@ +"""examples/snippets/clients/parsing_tool_results.py""" + +import asyncio + +from mcp import ClientSession, StdioServerParameters, types +from mcp.client.stdio import stdio_client + + +async def parse_tool_results(): + """Demonstrates how to parse different types of content in CallToolResult.""" + server_params = StdioServerParameters(command="python", args=["path/to/mcp_server.py"]) + + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # Example 1: Parsing text content + result = await session.call_tool("get_data", {"format": "text"}) + for content in result.content: + if isinstance(content, types.TextContent): + print(f"Text: {content.text}") + + # Example 2: Parsing structured content from JSON tools + result = await session.call_tool("get_user", {"id": "123"}) + if hasattr(result, "structuredContent") and result.structuredContent: + # Access structured data directly + user_data = result.structuredContent + print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}") + + # Example 3: Parsing embedded resources + result = await session.call_tool("read_config", {}) + for content in result.content: + if isinstance(content, types.EmbeddedResource): + resource = content.resource + if isinstance(resource, types.TextResourceContents): + print(f"Config from {resource.uri}: {resource.text}") + else: + print(f"Binary data from {resource.uri}") + + # Example 4: Parsing image content + result = await session.call_tool("generate_chart", {"data": [1, 2, 3]}) + for content in result.content: + if isinstance(content, types.ImageContent): + print(f"Image ({content.mimeType}): {len(content.data)} bytes") + + # Example 5: Handling errors + result = await session.call_tool("failing_tool", {}) + if result.isError: + print("Tool execution failed!") + for content in result.content: + if isinstance(content, types.TextContent): + print(f"Error: {content.text}") + + +async def main(): + await parse_tool_results() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/snippets/clients/stdio_client.py b/examples/snippets/clients/stdio_client.py new file mode 100644 index 0000000000..ac978035d4 --- /dev/null +++ b/examples/snippets/clients/stdio_client.py @@ -0,0 +1,83 @@ +""" +cd to the `examples/snippets/clients` directory and run: + uv run client +""" + +import asyncio +import os + +from pydantic import AnyUrl + +from mcp import ClientSession, StdioServerParameters, types +from mcp.client.stdio import stdio_client +from mcp.shared.context import RequestContext + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "fastmcp_quickstart", "stdio"], # We're already in snippets dir + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +# Optional: create a sampling callback +async def handle_sampling_message( + context: RequestContext[ClientSession, None], params: types.CreateMessageRequestParams +) -> types.CreateMessageResult: + print(f"Sampling request: {params.messages}") + return types.CreateMessageResult( + role="assistant", + content=types.TextContent( + type="text", + text="Hello, world! from model", + ), + model="gpt-3.5-turbo", + stopReason="endTurn", + ) + + +async def run(): + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: + # Initialize the connection + await session.initialize() + + # List available prompts + prompts = await session.list_prompts() + print(f"Available prompts: {[p.name for p in prompts.prompts]}") + + # Get a prompt (greet_user prompt from fastmcp_quickstart) + if prompts.prompts: + prompt = await session.get_prompt("greet_user", arguments={"name": "Alice", "style": "friendly"}) + print(f"Prompt result: {prompt.messages[0].content}") + + # List available resources + resources = await session.list_resources() + print(f"Available resources: {[r.uri for r in resources.resources]}") + + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}") + + # Read a resource (greeting resource from fastmcp_quickstart) + resource_content = await session.read_resource(AnyUrl("greeting://World")) + content_block = resource_content.contents[0] + if isinstance(content_block, types.TextContent): + print(f"Resource content: {content_block.text}") + + # Call a tool (add tool from fastmcp_quickstart) + result = await session.call_tool("add", arguments={"a": 5, "b": 3}) + result_unstructured = result.content[0] + if isinstance(result_unstructured, types.TextContent): + print(f"Tool result: {result_unstructured.text}") + result_structured = result.structuredContent + print(f"Structured tool result: {result_structured}") + + +def main(): + """Entry point for the client script.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/examples/snippets/clients/streamable_basic.py b/examples/snippets/clients/streamable_basic.py new file mode 100644 index 0000000000..071ea81553 --- /dev/null +++ b/examples/snippets/clients/streamable_basic.py @@ -0,0 +1,29 @@ +""" +Run from the repository root: + uv run examples/snippets/clients/streamable_basic.py +""" + +import asyncio + +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client + + +async def main(): + # Connect to a streamable HTTP server + async with streamable_http_client("http://localhost:8000/mcp") as ( + read_stream, + write_stream, + _, + ): + # Create a session using the client streams + async with ClientSession(read_stream, write_stream) as session: + # Initialize the connection + await session.initialize() + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/snippets/clients/url_elicitation_client.py b/examples/snippets/clients/url_elicitation_client.py new file mode 100644 index 0000000000..56457512c6 --- /dev/null +++ b/examples/snippets/clients/url_elicitation_client.py @@ -0,0 +1,318 @@ +"""URL Elicitation Client Example. + +Demonstrates how clients handle URL elicitation requests from servers. +This is the Python equivalent of TypeScript SDK's elicitationUrlExample.ts, +focused on URL elicitation patterns without OAuth complexity. + +Features demonstrated: +1. Client elicitation capability declaration +2. Handling elicitation requests from servers via callback +3. Catching UrlElicitationRequiredError from tool calls +4. Browser interaction with security warnings +5. Interactive CLI for testing + +Run with: + cd examples/snippets + uv run elicitation-client + +Requires a server with URL elicitation tools running. Start the elicitation +server first: + uv run server elicitation sse +""" + +from __future__ import annotations + +import asyncio +import json +import subprocess +import sys +import webbrowser +from typing import Any +from urllib.parse import urlparse + +from mcp import ClientSession, types +from mcp.client.sse import sse_client +from mcp.shared.context import RequestContext +from mcp.shared.exceptions import McpError, UrlElicitationRequiredError +from mcp.types import URL_ELICITATION_REQUIRED + + +async def handle_elicitation( + context: RequestContext[ClientSession, Any], + params: types.ElicitRequestParams, +) -> types.ElicitResult | types.ErrorData: + """Handle elicitation requests from the server. + + This callback is invoked when the server sends an elicitation/request. + For URL mode, we prompt the user and optionally open their browser. + """ + if params.mode == "url": + return await handle_url_elicitation(params) + else: + # We only support URL mode in this example + return types.ErrorData( + code=types.INVALID_REQUEST, + message=f"Unsupported elicitation mode: {params.mode}", + ) + + +async def handle_url_elicitation( + params: types.ElicitRequestParams, +) -> types.ElicitResult: + """Handle URL mode elicitation - show security warning and optionally open browser. + + This function demonstrates the security-conscious approach to URL elicitation: + 1. Display the full URL and domain for user inspection + 2. Show the server's reason for requesting this interaction + 3. Require explicit user consent before opening any URL + """ + # Extract URL parameters - these are available on URL mode requests + url = getattr(params, "url", None) + elicitation_id = getattr(params, "elicitationId", None) + message = params.message + + if not url: + print("Error: No URL provided in elicitation request") + return types.ElicitResult(action="cancel") + + # Extract domain for security display + domain = extract_domain(url) + + # Security warning - always show the user what they're being asked to do + print("\n" + "=" * 60) + print("SECURITY WARNING: External URL Request") + print("=" * 60) + print("\nThe server is requesting you to open an external URL.") + print(f"\n Domain: {domain}") + print(f" Full URL: {url}") + print("\n Server's reason:") + print(f" {message}") + print(f"\n Elicitation ID: {elicitation_id}") + print("\n" + "-" * 60) + + # Get explicit user consent + try: + response = input("\nOpen this URL in your browser? (y/n): ").strip().lower() + except EOFError: + return types.ElicitResult(action="cancel") + + if response in ("n", "no"): + print("URL navigation declined.") + return types.ElicitResult(action="decline") + elif response not in ("y", "yes"): + print("Invalid response. Cancelling.") + return types.ElicitResult(action="cancel") + + # Open the browser + print(f"\nOpening browser to: {url}") + open_browser(url) + + print("Waiting for you to complete the interaction in your browser...") + print("(The server will continue once you've finished)") + + return types.ElicitResult(action="accept") + + +def extract_domain(url: str) -> str: + """Extract domain from URL for security display.""" + try: + return urlparse(url).netloc + except Exception: + return "unknown" + + +def open_browser(url: str) -> None: + """Open URL in the default browser.""" + try: + if sys.platform == "darwin": + subprocess.run(["open", url], check=False) + elif sys.platform == "win32": + subprocess.run(["start", url], shell=True, check=False) + else: + webbrowser.open(url) + except Exception as e: + print(f"Failed to open browser: {e}") + print(f"Please manually open: {url}") + + +async def call_tool_with_error_handling( + session: ClientSession, + tool_name: str, + arguments: dict[str, Any], +) -> types.CallToolResult | None: + """Call a tool, handling UrlElicitationRequiredError if raised. + + When a server tool needs URL elicitation before it can proceed, + it can either: + 1. Send an elicitation request directly (handled by elicitation_callback) + 2. Return an error with code -32042 (URL_ELICITATION_REQUIRED) + + This function demonstrates handling case 2 - catching the error + and processing the required URL elicitations. + """ + try: + result = await session.call_tool(tool_name, arguments) + + # Check if the tool returned an error in the result + if result.isError: + print(f"Tool returned error: {result.content}") + return None + + return result + + except McpError as e: + # Check if this is a URL elicitation required error + if e.error.code == URL_ELICITATION_REQUIRED: + print("\n[Tool requires URL elicitation to proceed]") + + # Convert to typed error to access elicitations + url_error = UrlElicitationRequiredError.from_error(e.error) + + # Process each required elicitation + for elicitation in url_error.elicitations: + await handle_url_elicitation(elicitation) + + return None + else: + # Re-raise other MCP errors + print(f"MCP Error: {e.error.message} (code: {e.error.code})") + return None + + +def print_help() -> None: + """Print available commands.""" + print("\nAvailable commands:") + print(" list-tools - List available tools") + print(" call [json-args] - Call a tool with optional JSON arguments") + print(" secure-payment - Test URL elicitation via ctx.elicit_url()") + print(" connect-service - Test URL elicitation via UrlElicitationRequiredError") + print(" help - Show this help") + print(" quit - Exit the program") + + +def print_tool_result(result: types.CallToolResult | None) -> None: + """Print a tool call result.""" + if not result: + return + print("\nTool result:") + for content in result.content: + if isinstance(content, types.TextContent): + print(f" {content.text}") + else: + print(f" [{content.type}]") + + +async def handle_list_tools(session: ClientSession) -> None: + """Handle the list-tools command.""" + tools = await session.list_tools() + if tools.tools: + print("\nAvailable tools:") + for tool in tools.tools: + print(f" - {tool.name}: {tool.description or 'No description'}") + else: + print("No tools available") + + +async def handle_call_command(session: ClientSession, command: str) -> None: + """Handle the call command.""" + parts = command.split(maxsplit=2) + if len(parts) < 2: + print("Usage: call [json-args]") + return + + tool_name = parts[1] + args: dict[str, Any] = {} + if len(parts) > 2: + try: + args = json.loads(parts[2]) + except json.JSONDecodeError as e: + print(f"Invalid JSON arguments: {e}") + return + + print(f"\nCalling tool '{tool_name}' with args: {args}") + result = await call_tool_with_error_handling(session, tool_name, args) + print_tool_result(result) + + +async def process_command(session: ClientSession, command: str) -> bool: + """Process a single command. Returns False if should exit.""" + if command in {"quit", "exit"}: + print("Goodbye!") + return False + + if command == "help": + print_help() + elif command == "list-tools": + await handle_list_tools(session) + elif command.startswith("call "): + await handle_call_command(session, command) + elif command == "secure-payment": + print("\nTesting secure_payment tool (uses ctx.elicit_url())...") + result = await call_tool_with_error_handling(session, "secure_payment", {"amount": 99.99}) + print_tool_result(result) + elif command == "connect-service": + print("\nTesting connect_service tool (raises UrlElicitationRequiredError)...") + result = await call_tool_with_error_handling(session, "connect_service", {"service_name": "github"}) + print_tool_result(result) + else: + print(f"Unknown command: {command}") + print("Type 'help' for available commands.") + + return True + + +async def run_command_loop(session: ClientSession) -> None: + """Run the interactive command loop.""" + while True: + try: + command = input("> ").strip() + except EOFError: + break + except KeyboardInterrupt: + print("\n") + break + + if not command: + continue + + if not await process_command(session, command): + break + + +async def main() -> None: + """Run the interactive URL elicitation client.""" + server_url = "http://localhost:8000/sse" + + print("=" * 60) + print("URL Elicitation Client Example") + print("=" * 60) + print(f"\nConnecting to: {server_url}") + print("(Start server with: cd examples/snippets && uv run server elicitation sse)") + + try: + async with sse_client(server_url) as (read, write): + async with ClientSession( + read, + write, + elicitation_callback=handle_elicitation, + ) as session: + await session.initialize() + print("\nConnected! Type 'help' for available commands.\n") + await run_command_loop(session) + + except ConnectionRefusedError: + print(f"\nError: Could not connect to {server_url}") + print("Make sure the elicitation server is running:") + print(" cd examples/snippets && uv run server elicitation sse") + except Exception as e: + print(f"\nError: {e}") + raise + + +def run() -> None: + """Entry point for the client script.""" + asyncio.run(main()) + + +if __name__ == "__main__": + run() diff --git a/examples/snippets/pyproject.toml b/examples/snippets/pyproject.toml new file mode 100644 index 0000000000..4e68846a09 --- /dev/null +++ b/examples/snippets/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "mcp-snippets" +version = "0.1.0" +description = "MCP Example Snippets" +requires-python = ">=3.10" +dependencies = [ + "mcp", +] + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["servers", "clients"] + +[project.scripts] +server = "servers:run_server" +client = "clients.stdio_client:main" +completion-client = "clients.completion_client:main" +direct-execution-server = "servers.direct_execution:main" +display-utilities-client = "clients.display_utilities:main" +oauth-client = "clients.oauth_client:run" +elicitation-client = "clients.url_elicitation_client:run" diff --git a/examples/snippets/servers/__init__.py b/examples/snippets/servers/__init__.py new file mode 100644 index 0000000000..b9865e822a --- /dev/null +++ b/examples/snippets/servers/__init__.py @@ -0,0 +1,37 @@ +"""MCP Snippets. + +This package contains simple examples of MCP server features. +Each server demonstrates a single feature and can be run as a standalone server. + +To run a server, use the command: + uv run server basic_tool sse +""" + +import importlib +import sys +from typing import Literal, cast + + +def run_server(): + """Run a server by name with optional transport. + + Usage: server [transport] + Example: server basic_tool sse + """ + if len(sys.argv) < 2: + print("Usage: server [transport]") + print("Available servers: basic_tool, basic_resource, basic_prompt, tool_progress,") + print(" sampling, elicitation, completion, notifications,") + print(" fastmcp_quickstart, structured_output, images") + print("Available transports: stdio (default), sse, streamable-http") + sys.exit(1) + + server_name = sys.argv[1] + transport = sys.argv[2] if len(sys.argv) > 2 else "stdio" + + try: + module = importlib.import_module(f".{server_name}", package=__name__) + module.mcp.run(cast(Literal["stdio", "sse", "streamable-http"], transport)) + except ImportError: + print(f"Error: Server '{server_name}' not found") + sys.exit(1) diff --git a/examples/snippets/servers/basic_prompt.py b/examples/snippets/servers/basic_prompt.py new file mode 100644 index 0000000000..40f606ba69 --- /dev/null +++ b/examples/snippets/servers/basic_prompt.py @@ -0,0 +1,18 @@ +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.prompts import base + +mcp = FastMCP(name="Prompt Example") + + +@mcp.prompt(title="Code Review") +def review_code(code: str) -> str: + return f"Please review this code:\n\n{code}" + + +@mcp.prompt(title="Debug Assistant") +def debug_error(error: str) -> list[base.Message]: + return [ + base.UserMessage("I'm seeing this error:"), + base.UserMessage(error), + base.AssistantMessage("I'll help debug that. What have you tried so far?"), + ] diff --git a/examples/snippets/servers/basic_resource.py b/examples/snippets/servers/basic_resource.py new file mode 100644 index 0000000000..5c19730595 --- /dev/null +++ b/examples/snippets/servers/basic_resource.py @@ -0,0 +1,20 @@ +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP(name="Resource Example") + + +@mcp.resource("file://documents/{name}") +def read_document(name: str) -> str: + """Read a document by name.""" + # This would normally read from disk + return f"Content of {name}" + + +@mcp.resource("config://settings") +def get_settings() -> str: + """Get application settings.""" + return """{ + "theme": "dark", + "language": "en", + "debug": false +}""" diff --git a/examples/snippets/servers/basic_tool.py b/examples/snippets/servers/basic_tool.py new file mode 100644 index 0000000000..550e240808 --- /dev/null +++ b/examples/snippets/servers/basic_tool.py @@ -0,0 +1,16 @@ +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP(name="Tool Example") + + +@mcp.tool() +def sum(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + + +@mcp.tool() +def get_weather(city: str, unit: str = "celsius") -> str: + """Get weather for a city.""" + # This would normally call a weather API + return f"Weather in {city}: 22degrees{unit[0].upper()}" diff --git a/examples/snippets/servers/completion.py b/examples/snippets/servers/completion.py new file mode 100644 index 0000000000..2a31541ddc --- /dev/null +++ b/examples/snippets/servers/completion.py @@ -0,0 +1,49 @@ +from mcp.server.fastmcp import FastMCP +from mcp.types import ( + Completion, + CompletionArgument, + CompletionContext, + PromptReference, + ResourceTemplateReference, +) + +mcp = FastMCP(name="Example") + + +@mcp.resource("github://repos/{owner}/{repo}") +def github_repo(owner: str, repo: str) -> str: + """GitHub repository resource.""" + return f"Repository: {owner}/{repo}" + + +@mcp.prompt(description="Code review prompt") +def review_code(language: str, code: str) -> str: + """Generate a code review.""" + return f"Review this {language} code:\n{code}" + + +@mcp.completion() +async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion | None: + """Provide completions for prompts and resources.""" + + # Complete programming languages for the prompt + if isinstance(ref, PromptReference): + if ref.name == "review_code" and argument.name == "language": + languages = ["python", "javascript", "typescript", "go", "rust"] + return Completion( + values=[lang for lang in languages if lang.startswith(argument.value)], + hasMore=False, + ) + + # Complete repository names for GitHub resources + if isinstance(ref, ResourceTemplateReference): + if ref.uri == "github://repos/{owner}/{repo}" and argument.name == "repo": + if context and context.arguments and context.arguments.get("owner") == "modelcontextprotocol": + repos = ["python-sdk", "typescript-sdk", "specification"] + return Completion(values=repos, hasMore=False) + + return None diff --git a/examples/snippets/servers/direct_call_tool_result.py b/examples/snippets/servers/direct_call_tool_result.py new file mode 100644 index 0000000000..54d49b2f66 --- /dev/null +++ b/examples/snippets/servers/direct_call_tool_result.py @@ -0,0 +1,42 @@ +"""Example showing direct CallToolResult return for advanced control.""" + +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server.fastmcp import FastMCP +from mcp.types import CallToolResult, TextContent + +mcp = FastMCP("CallToolResult Example") + + +class ValidationModel(BaseModel): + """Model for validating structured output.""" + + status: str + data: dict[str, int] + + +@mcp.tool() +def advanced_tool() -> CallToolResult: + """Return CallToolResult directly for full control including _meta field.""" + return CallToolResult( + content=[TextContent(type="text", text="Response visible to the model")], + _meta={"hidden": "data for client applications only"}, + ) + + +@mcp.tool() +def validated_tool() -> Annotated[CallToolResult, ValidationModel]: + """Return CallToolResult with structured output validation.""" + return CallToolResult( + content=[TextContent(type="text", text="Validated response")], + structuredContent={"status": "success", "data": {"result": 42}}, + _meta={"internal": "metadata"}, + ) + + +@mcp.tool() +def empty_result_tool() -> CallToolResult: + """For empty results, return CallToolResult with empty content.""" + return CallToolResult(content=[]) diff --git a/examples/snippets/servers/direct_execution.py b/examples/snippets/servers/direct_execution.py new file mode 100644 index 0000000000..65a6fbbf39 --- /dev/null +++ b/examples/snippets/servers/direct_execution.py @@ -0,0 +1,27 @@ +"""Example showing direct execution of an MCP server. + +This is the simplest way to run an MCP server directly. +cd to the `examples/snippets` directory and run: + uv run direct-execution-server + or + python servers/direct_execution.py +""" + +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("My App") + + +@mcp.tool() +def hello(name: str = "World") -> str: + """Say hello to someone.""" + return f"Hello, {name}!" + + +def main(): + """Entry point for the direct execution server.""" + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/examples/snippets/servers/elicitation.py b/examples/snippets/servers/elicitation.py new file mode 100644 index 0000000000..a1a65fb32c --- /dev/null +++ b/examples/snippets/servers/elicitation.py @@ -0,0 +1,99 @@ +"""Elicitation examples demonstrating form and URL mode elicitation. + +Form mode elicitation collects structured, non-sensitive data through a schema. +URL mode elicitation directs users to external URLs for sensitive operations +like OAuth flows, credential collection, or payment processing. +""" + +import uuid + +from pydantic import BaseModel, Field + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +from mcp.shared.exceptions import UrlElicitationRequiredError +from mcp.types import ElicitRequestURLParams + +mcp = FastMCP(name="Elicitation Example") + + +class BookingPreferences(BaseModel): + """Schema for collecting user preferences.""" + + checkAlternative: bool = Field(description="Would you like to check another date?") + alternativeDate: str = Field( + default="2024-12-26", + description="Alternative date (YYYY-MM-DD)", + ) + + +@mcp.tool() +async def book_table(date: str, time: str, party_size: int, ctx: Context[ServerSession, None]) -> str: + """Book a table with date availability check. + + This demonstrates form mode elicitation for collecting non-sensitive user input. + """ + # Check if date is available + if date == "2024-12-25": + # Date unavailable - ask user for alternative + result = await ctx.elicit( + message=(f"No tables available for {party_size} on {date}. Would you like to try another date?"), + schema=BookingPreferences, + ) + + if result.action == "accept" and result.data: + if result.data.checkAlternative: + return f"[SUCCESS] Booked for {result.data.alternativeDate}" + return "[CANCELLED] No booking made" + return "[CANCELLED] Booking cancelled" + + # Date available + return f"[SUCCESS] Booked for {date} at {time}" + + +@mcp.tool() +async def secure_payment(amount: float, ctx: Context[ServerSession, None]) -> str: + """Process a secure payment requiring URL confirmation. + + This demonstrates URL mode elicitation using ctx.elicit_url() for + operations that require out-of-band user interaction. + """ + elicitation_id = str(uuid.uuid4()) + + result = await ctx.elicit_url( + message=f"Please confirm payment of ${amount:.2f}", + url=f"https://payments.example.com/confirm?amount={amount}&id={elicitation_id}", + elicitation_id=elicitation_id, + ) + + if result.action == "accept": + # In a real app, the payment confirmation would happen out-of-band + # and you'd verify the payment status from your backend + return f"Payment of ${amount:.2f} initiated - check your browser to complete" + elif result.action == "decline": + return "Payment declined by user" + return "Payment cancelled" + + +@mcp.tool() +async def connect_service(service_name: str, ctx: Context[ServerSession, None]) -> str: + """Connect to a third-party service requiring OAuth authorization. + + This demonstrates the "throw error" pattern using UrlElicitationRequiredError. + Use this pattern when the tool cannot proceed without user authorization. + """ + elicitation_id = str(uuid.uuid4()) + + # Raise UrlElicitationRequiredError to signal that the client must complete + # a URL elicitation before this request can be processed. + # The MCP framework will convert this to a -32042 error response. + raise UrlElicitationRequiredError( + [ + ElicitRequestURLParams( + mode="url", + message=f"Authorization required to connect to {service_name}", + url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}", + elicitationId=elicitation_id, + ) + ] + ) diff --git a/examples/snippets/servers/fastmcp_quickstart.py b/examples/snippets/servers/fastmcp_quickstart.py new file mode 100644 index 0000000000..931cd263f8 --- /dev/null +++ b/examples/snippets/servers/fastmcp_quickstart.py @@ -0,0 +1,43 @@ +""" +FastMCP quickstart example. + +Run from the repository root: + uv run examples/snippets/servers/fastmcp_quickstart.py +""" + +from mcp.server.fastmcp import FastMCP + +# Create an MCP server +mcp = FastMCP("Demo", json_response=True) + + +# Add an addition tool +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b + + +# Add a dynamic greeting resource +@mcp.resource("greeting://{name}") +def get_greeting(name: str) -> str: + """Get a personalized greeting""" + return f"Hello, {name}!" + + +# Add a prompt +@mcp.prompt() +def greet_user(name: str, style: str = "friendly") -> str: + """Generate a greeting prompt""" + styles = { + "friendly": "Please write a warm, friendly greeting", + "formal": "Please write a formal, professional greeting", + "casual": "Please write a casual, relaxed greeting", + } + + return f"{styles.get(style, styles['friendly'])} for someone named {name}." + + +# Run with streamable HTTP transport +if __name__ == "__main__": + mcp.run(transport="streamable-http") diff --git a/examples/snippets/servers/images.py b/examples/snippets/servers/images.py new file mode 100644 index 0000000000..9e0262c853 --- /dev/null +++ b/examples/snippets/servers/images.py @@ -0,0 +1,15 @@ +"""Example showing image handling with FastMCP.""" + +from PIL import Image as PILImage + +from mcp.server.fastmcp import FastMCP, Image + +mcp = FastMCP("Image Example") + + +@mcp.tool() +def create_thumbnail(image_path: str) -> Image: + """Create a thumbnail from an image""" + img = PILImage.open(image_path) + img.thumbnail((100, 100)) + return Image(data=img.tobytes(), format="png") diff --git a/examples/snippets/servers/lifespan_example.py b/examples/snippets/servers/lifespan_example.py new file mode 100644 index 0000000000..62278b6aac --- /dev/null +++ b/examples/snippets/servers/lifespan_example.py @@ -0,0 +1,57 @@ +"""Example showing lifespan support for startup/shutdown with strong typing.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + + +# Mock database class for example +class Database: + """Mock database class for example.""" + + @classmethod + async def connect(cls) -> "Database": + """Connect to database.""" + return cls() + + async def disconnect(self) -> None: + """Disconnect from database.""" + pass + + def query(self) -> str: + """Execute a query.""" + return "Query result" + + +@dataclass +class AppContext: + """Application context with typed dependencies.""" + + db: Database + + +@asynccontextmanager +async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: + """Manage application lifecycle with type-safe context.""" + # Initialize on startup + db = await Database.connect() + try: + yield AppContext(db=db) + finally: + # Cleanup on shutdown + await db.disconnect() + + +# Pass lifespan to server +mcp = FastMCP("My App", lifespan=app_lifespan) + + +# Access type-safe lifespan context in tools +@mcp.tool() +def query_db(ctx: Context[ServerSession, AppContext]) -> str: + """Tool that uses initialized resources.""" + db = ctx.request_context.lifespan_context.db + return db.query() diff --git a/examples/snippets/servers/lowlevel/__init__.py b/examples/snippets/servers/lowlevel/__init__.py new file mode 100644 index 0000000000..c6ae62db67 --- /dev/null +++ b/examples/snippets/servers/lowlevel/__init__.py @@ -0,0 +1 @@ +"""Low-level server examples for MCP Python SDK.""" diff --git a/examples/snippets/servers/lowlevel/basic.py b/examples/snippets/servers/lowlevel/basic.py new file mode 100644 index 0000000000..a5c4149df7 --- /dev/null +++ b/examples/snippets/servers/lowlevel/basic.py @@ -0,0 +1,66 @@ +""" +Run from the repository root: +uv run examples/snippets/servers/lowlevel/basic.py +""" + +import asyncio + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +# Create a server instance +server = Server("example-server") + + +@server.list_prompts() +async def handle_list_prompts() -> list[types.Prompt]: + """List available prompts.""" + return [ + types.Prompt( + name="example-prompt", + description="An example prompt template", + arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], + ) + ] + + +@server.get_prompt() +async def handle_get_prompt(name: str, arguments: dict[str, str] | None) -> types.GetPromptResult: + """Get a specific prompt by name.""" + if name != "example-prompt": + raise ValueError(f"Unknown prompt: {name}") + + arg1_value = (arguments or {}).get("arg1", "default") + + return types.GetPromptResult( + description="Example prompt", + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text=f"Example prompt text with argument: {arg1_value}"), + ) + ], + ) + + +async def run(): + """Run the basic low-level server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/examples/snippets/servers/lowlevel/direct_call_tool_result.py b/examples/snippets/servers/lowlevel/direct_call_tool_result.py new file mode 100644 index 0000000000..496eaad105 --- /dev/null +++ b/examples/snippets/servers/lowlevel/direct_call_tool_result.py @@ -0,0 +1,65 @@ +""" +Run from the repository root: + uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py +""" + +import asyncio +from typing import Any + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +server = Server("example-server") + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """List available tools.""" + return [ + types.Tool( + name="advanced_tool", + description="Tool with full control including _meta field", + inputSchema={ + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + }, + ) + ] + + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult: + """Handle tool calls by returning CallToolResult directly.""" + if name == "advanced_tool": + message = str(arguments.get("message", "")) + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Processed: {message}")], + structuredContent={"result": "success", "message": message}, + _meta={"hidden": "data for client applications only"}, + ) + + raise ValueError(f"Unknown tool: {name}") + + +async def run(): + """Run the server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/examples/snippets/servers/lowlevel/lifespan.py b/examples/snippets/servers/lowlevel/lifespan.py new file mode 100644 index 0000000000..ada3731224 --- /dev/null +++ b/examples/snippets/servers/lowlevel/lifespan.py @@ -0,0 +1,104 @@ +""" +Run from the repository root: + uv run examples/snippets/servers/lowlevel/lifespan.py +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + + +# Mock database class for example +class Database: + """Mock database class for example.""" + + @classmethod + async def connect(cls) -> "Database": + """Connect to database.""" + print("Database connected") + return cls() + + async def disconnect(self) -> None: + """Disconnect from database.""" + print("Database disconnected") + + async def query(self, query_str: str) -> list[dict[str, str]]: + """Execute a query.""" + # Simulate database query + return [{"id": "1", "name": "Example", "query": query_str}] + + +@asynccontextmanager +async def server_lifespan(_server: Server) -> AsyncIterator[dict[str, Any]]: + """Manage server startup and shutdown lifecycle.""" + # Initialize resources on startup + db = await Database.connect() + try: + yield {"db": db} + finally: + # Clean up on shutdown + await db.disconnect() + + +# Pass lifespan to server +server = Server("example-server", lifespan=server_lifespan) + + +@server.list_tools() +async def handle_list_tools() -> list[types.Tool]: + """List available tools.""" + return [ + types.Tool( + name="query_db", + description="Query the database", + inputSchema={ + "type": "object", + "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def query_db(name: str, arguments: dict[str, Any]) -> list[types.TextContent]: + """Handle database query tool call.""" + if name != "query_db": + raise ValueError(f"Unknown tool: {name}") + + # Access lifespan context + ctx = server.request_context + db = ctx.lifespan_context["db"] + + # Execute query + results = await db.query(arguments["query"]) + + return [types.TextContent(type="text", text=f"Query results: {results}")] + + +async def run(): + """Run the server with lifespan management.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="example-server", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + import asyncio + + asyncio.run(run()) diff --git a/examples/snippets/servers/lowlevel/structured_output.py b/examples/snippets/servers/lowlevel/structured_output.py new file mode 100644 index 0000000000..0237c9ab31 --- /dev/null +++ b/examples/snippets/servers/lowlevel/structured_output.py @@ -0,0 +1,83 @@ +""" +Run from the repository root: + uv run examples/snippets/servers/lowlevel/structured_output.py +""" + +import asyncio +from typing import Any + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +server = Server("example-server") + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """List available tools with structured output schemas.""" + return [ + types.Tool( + name="get_weather", + description="Get current weather for a city", + inputSchema={ + "type": "object", + "properties": {"city": {"type": "string", "description": "City name"}}, + "required": ["city"], + }, + outputSchema={ + "type": "object", + "properties": { + "temperature": {"type": "number", "description": "Temperature in Celsius"}, + "condition": {"type": "string", "description": "Weather condition"}, + "humidity": {"type": "number", "description": "Humidity percentage"}, + "city": {"type": "string", "description": "City name"}, + }, + "required": ["temperature", "condition", "humidity", "city"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + """Handle tool calls with structured output.""" + if name == "get_weather": + city = arguments["city"] + + # Simulated weather data - in production, call a weather API + weather_data = { + "temperature": 22.5, + "condition": "partly cloudy", + "humidity": 65, + "city": city, # Include the requested city + } + + # low-level server will validate structured output against the tool's + # output schema, and additionally serialize it into a TextContent block + # for backwards compatibility with pre-2025-06-18 clients. + return weather_data + else: + raise ValueError(f"Unknown tool: {name}") + + +async def run(): + """Run the structured output server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="structured-output-example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/examples/snippets/servers/notifications.py b/examples/snippets/servers/notifications.py new file mode 100644 index 0000000000..833bc89053 --- /dev/null +++ b/examples/snippets/servers/notifications.py @@ -0,0 +1,19 @@ +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP(name="Notifications Example") + + +@mcp.tool() +async def process_data(data: str, ctx: Context[ServerSession, None]) -> str: + """Process data with logging.""" + # Different log levels + await ctx.debug(f"Debug: Processing '{data}'") + await ctx.info("Info: Starting processing") + await ctx.warning("Warning: This is experimental") + await ctx.error("Error: (This is just a demo)") + + # Notify about resource changes + await ctx.session.send_resource_list_changed() + + return f"Processed: {data}" diff --git a/examples/snippets/servers/oauth_server.py b/examples/snippets/servers/oauth_server.py new file mode 100644 index 0000000000..3717c66de8 --- /dev/null +++ b/examples/snippets/servers/oauth_server.py @@ -0,0 +1,47 @@ +""" +Run from the repository root: + uv run examples/snippets/servers/oauth_server.py +""" + +from pydantic import AnyHttpUrl + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp import FastMCP + + +class SimpleTokenVerifier(TokenVerifier): + """Simple token verifier for demonstration.""" + + async def verify_token(self, token: str) -> AccessToken | None: + pass # This is where you would implement actual token validation + + +# Create FastMCP instance as a Resource Server +mcp = FastMCP( + "Weather Service", + json_response=True, + # Token verifier for authentication + token_verifier=SimpleTokenVerifier(), + # Auth settings for RFC 9728 Protected Resource Metadata + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL + resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL + required_scopes=["user"], + ), +) + + +@mcp.tool() +async def get_weather(city: str = "London") -> dict[str, str]: + """Get weather data for a city""" + return { + "city": city, + "temperature": "22", + "condition": "Partly cloudy", + "humidity": "65%", + } + + +if __name__ == "__main__": + mcp.run(transport="streamable-http") diff --git a/examples/snippets/servers/pagination_example.py b/examples/snippets/servers/pagination_example.py new file mode 100644 index 0000000000..70c3b3492c --- /dev/null +++ b/examples/snippets/servers/pagination_example.py @@ -0,0 +1,38 @@ +""" +Example of implementing pagination with MCP server decorators. +""" + +from pydantic import AnyUrl + +import mcp.types as types +from mcp.server.lowlevel import Server + +# Initialize the server +server = Server("paginated-server") + +# Sample data to paginate +ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items + + +@server.list_resources() +async def list_resources_paginated(request: types.ListResourcesRequest) -> types.ListResourcesResult: + """List resources with pagination support.""" + page_size = 10 + + # Extract cursor from request params + cursor = request.params.cursor if request.params is not None else None + + # Parse cursor to get offset + start = 0 if cursor is None else int(cursor) + end = start + page_size + + # Get page of resources + page_items = [ + types.Resource(uri=AnyUrl(f"resource://items/{item}"), name=item, description=f"Description for {item}") + for item in ITEMS[start:end] + ] + + # Determine next cursor + next_cursor = str(end) if end < len(ITEMS) else None + + return types.ListResourcesResult(resources=page_items, nextCursor=next_cursor) diff --git a/examples/snippets/servers/sampling.py b/examples/snippets/servers/sampling.py new file mode 100644 index 0000000000..ae78a74ace --- /dev/null +++ b/examples/snippets/servers/sampling.py @@ -0,0 +1,26 @@ +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +from mcp.types import SamplingMessage, TextContent + +mcp = FastMCP(name="Sampling Example") + + +@mcp.tool() +async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str: + """Generate a poem using LLM sampling.""" + prompt = f"Write a short poem about {topic}" + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt), + ) + ], + max_tokens=100, + ) + + # Since we're not passing tools param, result.content is single content + if result.content.type == "text": + return result.content.text + return str(result.content) diff --git a/examples/snippets/servers/streamable_config.py b/examples/snippets/servers/streamable_config.py new file mode 100644 index 0000000000..d351a45d86 --- /dev/null +++ b/examples/snippets/servers/streamable_config.py @@ -0,0 +1,28 @@ +""" +Run from the repository root: + uv run examples/snippets/servers/streamable_config.py +""" + +from mcp.server.fastmcp import FastMCP + +# Stateless server with JSON responses (recommended) +mcp = FastMCP("StatelessServer", stateless_http=True, json_response=True) + +# Other configuration options: +# Stateless server with SSE streaming responses +# mcp = FastMCP("StatelessServer", stateless_http=True) + +# Stateful server with session persistence +# mcp = FastMCP("StatefulServer") + + +# Add a simple tool to demonstrate the server +@mcp.tool() +def greet(name: str = "World") -> str: + """Greet someone by name.""" + return f"Hello, {name}!" + + +# Run server with streamable_http transport +if __name__ == "__main__": + mcp.run(transport="streamable-http") diff --git a/examples/snippets/servers/streamable_http_basic_mounting.py b/examples/snippets/servers/streamable_http_basic_mounting.py new file mode 100644 index 0000000000..74aa36ed4f --- /dev/null +++ b/examples/snippets/servers/streamable_http_basic_mounting.py @@ -0,0 +1,38 @@ +""" +Basic example showing how to mount StreamableHTTP server in Starlette. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_basic_mounting:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.fastmcp import FastMCP + +# Create MCP server +mcp = FastMCP("My App", json_response=True) + + +@mcp.tool() +def hello() -> str: + """A simple hello tool""" + return "Hello from MCP!" + + +# Create a lifespan context manager to run the session manager +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with mcp.session_manager.run(): + yield + + +# Mount the StreamableHTTP server to the existing ASGI server +app = Starlette( + routes=[ + Mount("/", app=mcp.streamable_http_app()), + ], + lifespan=lifespan, +) diff --git a/examples/snippets/servers/streamable_http_host_mounting.py b/examples/snippets/servers/streamable_http_host_mounting.py new file mode 100644 index 0000000000..3ae9d341e1 --- /dev/null +++ b/examples/snippets/servers/streamable_http_host_mounting.py @@ -0,0 +1,38 @@ +""" +Example showing how to mount StreamableHTTP server using Host-based routing. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_host_mounting:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Host + +from mcp.server.fastmcp import FastMCP + +# Create MCP server +mcp = FastMCP("MCP Host App", json_response=True) + + +@mcp.tool() +def domain_info() -> str: + """Get domain-specific information""" + return "This is served from mcp.acme.corp" + + +# Create a lifespan context manager to run the session manager +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with mcp.session_manager.run(): + yield + + +# Mount using Host-based routing +app = Starlette( + routes=[ + Host("mcp.acme.corp", app=mcp.streamable_http_app()), + ], + lifespan=lifespan, +) diff --git a/examples/snippets/servers/streamable_http_multiple_servers.py b/examples/snippets/servers/streamable_http_multiple_servers.py new file mode 100644 index 0000000000..8d0a1018d2 --- /dev/null +++ b/examples/snippets/servers/streamable_http_multiple_servers.py @@ -0,0 +1,54 @@ +""" +Example showing how to mount multiple StreamableHTTP servers with path configuration. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_multiple_servers:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.fastmcp import FastMCP + +# Create multiple MCP servers +api_mcp = FastMCP("API Server", json_response=True) +chat_mcp = FastMCP("Chat Server", json_response=True) + + +@api_mcp.tool() +def api_status() -> str: + """Get API status""" + return "API is running" + + +@chat_mcp.tool() +def send_message(message: str) -> str: + """Send a chat message""" + return f"Message sent: {message}" + + +# Configure servers to mount at the root of each path +# This means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp +api_mcp.settings.streamable_http_path = "/" +chat_mcp.settings.streamable_http_path = "/" + + +# Create a combined lifespan to manage both session managers +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(api_mcp.session_manager.run()) + await stack.enter_async_context(chat_mcp.session_manager.run()) + yield + + +# Mount the servers +app = Starlette( + routes=[ + Mount("/api", app=api_mcp.streamable_http_app()), + Mount("/chat", app=chat_mcp.streamable_http_app()), + ], + lifespan=lifespan, +) diff --git a/examples/snippets/servers/streamable_http_path_config.py b/examples/snippets/servers/streamable_http_path_config.py new file mode 100644 index 0000000000..9fabf12fa7 --- /dev/null +++ b/examples/snippets/servers/streamable_http_path_config.py @@ -0,0 +1,33 @@ +""" +Example showing path configuration during FastMCP initialization. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_path_config:app --reload +""" + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.fastmcp import FastMCP + +# Configure streamable_http_path during initialization +# This server will mount at the root of wherever it's mounted +mcp_at_root = FastMCP( + "My Server", + json_response=True, + streamable_http_path="/", +) + + +@mcp_at_root.tool() +def process_data(data: str) -> str: + """Process some data""" + return f"Processed: {data}" + + +# Mount at /process - endpoints will be at /process instead of /process/mcp +app = Starlette( + routes=[ + Mount("/process", app=mcp_at_root.streamable_http_app()), + ] +) diff --git a/examples/snippets/servers/streamable_starlette_mount.py b/examples/snippets/servers/streamable_starlette_mount.py new file mode 100644 index 0000000000..b3a630b0f5 --- /dev/null +++ b/examples/snippets/servers/streamable_starlette_mount.py @@ -0,0 +1,54 @@ +""" +Run from the repository root: + uvicorn examples.snippets.servers.streamable_starlette_mount:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.fastmcp import FastMCP + +# Create the Echo server +echo_mcp = FastMCP(name="EchoServer", stateless_http=True, json_response=True) + + +@echo_mcp.tool() +def echo(message: str) -> str: + """A simple echo tool""" + return f"Echo: {message}" + + +# Create the Math server +math_mcp = FastMCP(name="MathServer", stateless_http=True, json_response=True) + + +@math_mcp.tool() +def add_two(n: int) -> int: + """Tool to add two to the input""" + return n + 2 + + +# Create a combined lifespan to manage both session managers +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(echo_mcp.session_manager.run()) + await stack.enter_async_context(math_mcp.session_manager.run()) + yield + + +# Create the Starlette app and mount the MCP servers +app = Starlette( + routes=[ + Mount("/echo", echo_mcp.streamable_http_app()), + Mount("/math", math_mcp.streamable_http_app()), + ], + lifespan=lifespan, +) + +# Note: Clients connect to http://localhost:8000/echo/mcp and http://localhost:8000/math/mcp +# To mount at the root of each path (e.g., /echo instead of /echo/mcp): +# echo_mcp.settings.streamable_http_path = "/" +# math_mcp.settings.streamable_http_path = "/" diff --git a/examples/snippets/servers/structured_output.py b/examples/snippets/servers/structured_output.py new file mode 100644 index 0000000000..50ee130c7e --- /dev/null +++ b/examples/snippets/servers/structured_output.py @@ -0,0 +1,97 @@ +"""Example showing structured output with tools.""" + +from typing import TypedDict + +from pydantic import BaseModel, Field + +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Structured Output Example") + + +# Using Pydantic models for rich structured data +class WeatherData(BaseModel): + """Weather information structure.""" + + temperature: float = Field(description="Temperature in Celsius") + humidity: float = Field(description="Humidity percentage") + condition: str + wind_speed: float + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Get weather for a city - returns structured data.""" + # Simulated weather data + return WeatherData( + temperature=22.5, + humidity=45.0, + condition="sunny", + wind_speed=5.2, + ) + + +# Using TypedDict for simpler structures +class LocationInfo(TypedDict): + latitude: float + longitude: float + name: str + + +@mcp.tool() +def get_location(address: str) -> LocationInfo: + """Get location coordinates""" + return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK") + + +# Using dict[str, Any] for flexible schemas +@mcp.tool() +def get_statistics(data_type: str) -> dict[str, float]: + """Get various statistics""" + return {"mean": 42.5, "median": 40.0, "std_dev": 5.2} + + +# Ordinary classes with type hints work for structured output +class UserProfile: + name: str + age: int + email: str | None = None + + def __init__(self, name: str, age: int, email: str | None = None): + self.name = name + self.age = age + self.email = email + + +@mcp.tool() +def get_user(user_id: str) -> UserProfile: + """Get user profile - returns structured data""" + return UserProfile(name="Alice", age=30, email="alice@example.com") + + +# Classes WITHOUT type hints cannot be used for structured output +class UntypedConfig: + def __init__(self, setting1, setting2): # type: ignore[reportMissingParameterType] + self.setting1 = setting1 + self.setting2 = setting2 + + +@mcp.tool() +def get_config() -> UntypedConfig: + """This returns unstructured output - no schema generated""" + return UntypedConfig("value1", "value2") + + +# Lists and other types are wrapped automatically +@mcp.tool() +def list_cities() -> list[str]: + """Get a list of cities""" + return ["London", "Paris", "Tokyo"] + # Returns: {"result": ["London", "Paris", "Tokyo"]} + + +@mcp.tool() +def get_temperature(city: str) -> float: + """Get temperature as a simple float""" + return 22.5 + # Returns: {"result": 22.5} diff --git a/examples/snippets/servers/tool_progress.py b/examples/snippets/servers/tool_progress.py new file mode 100644 index 0000000000..2ac458f6aa --- /dev/null +++ b/examples/snippets/servers/tool_progress.py @@ -0,0 +1,21 @@ +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP(name="Progress Example") + + +@mcp.tool() +async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: + """Execute a task with progress updates.""" + await ctx.info(f"Starting: {task_name}") + + for i in range(steps): + progress = (i + 1) / steps + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Step {i + 1}/{steps}", + ) + await ctx.debug(f"Completed step {i + 1}") + + return f"Task '{task_name}' completed" diff --git a/mkdocs.yml b/mkdocs.yml index b907cb8737..22c323d9d4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,7 +11,19 @@ site_url: https://modelcontextprotocol.github.io/python-sdk # copyright: © Model Context Protocol 2025 to present nav: - - Home: index.md + - Introduction: index.md + - Installation: installation.md + - Documentation: + - Concepts: concepts.md + - Low-Level Server: low-level-server.md + - Authorization: authorization.md + - Testing: testing.md + - Experimental: + - Overview: experimental/index.md + - Tasks: + - Introduction: experimental/tasks.md + - Server Implementation: experimental/tasks-server.md + - Client Usage: experimental/tasks-client.md - API Reference: api.md theme: diff --git a/pyproject.toml b/pyproject.toml index 1aaf155937..078a1dfdcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,36 +23,46 @@ classifiers = [ ] dependencies = [ "anyio>=4.5", - "httpx>=0.27", + "httpx>=0.27.1", "httpx-sse>=0.4", - "pydantic>=2.7.2,<3.0.0", + "pydantic>=2.11.0,<3.0.0", "starlette>=0.27", + "python-multipart>=0.0.9", "sse-starlette>=1.6.1", "pydantic-settings>=2.5.2", - "uvicorn>=0.23.1; sys_platform != 'emscripten'", + "uvicorn>=0.31.1; sys_platform != 'emscripten'", + "jsonschema>=4.20.0", + "pywin32>=310; sys_platform == 'win32'", + "pyjwt[crypto]>=2.10.1", + "typing-extensions>=4.9.0", + "typing-inspection>=0.4.1", ] [project.optional-dependencies] rich = ["rich>=13.9.4"] -cli = ["typer>=0.12.4", "python-dotenv>=1.0.0"] +cli = ["typer>=0.16.0", "python-dotenv>=1.0.0"] ws = ["websockets>=15.0.1"] [project.scripts] mcp = "mcp.cli:app [cli]" [tool.uv] -resolution = "lowest-direct" default-groups = ["dev", "docs"] +required-version = ">=0.9.5" [dependency-groups] dev = [ - "pyright>=1.1.391", + "pyright>=1.1.400", "pytest>=8.3.4", "ruff>=0.8.5", "trio>=0.26.2", "pytest-flakefinder>=1.1.0", "pytest-xdist>=3.6.1", "pytest-examples>=0.0.14", + "pytest-pretty>=1.2.0", + "inline-snapshot>=0.23.0", + "dirty-equals>=0.9.0", + "coverage[toml]==7.10.7", ] docs = [ "mkdocs>=1.6.1", @@ -61,7 +71,6 @@ docs = [ "mkdocstrings-python>=1.12.2", ] - [build-system] requires = ["hatchling", "uv-dynamic-versioning"] build-backend = "hatchling.build" @@ -83,35 +92,119 @@ Issues = "https://github.com/modelcontextprotocol/python-sdk/issues" packages = ["src/mcp"] [tool.pyright] -include = ["src/mcp", "tests"] +typeCheckingMode = "strict" +include = ["src/mcp", "tests", "examples/servers", "examples/snippets"] venvPath = "." venv = ".venv" -strict = ["src/mcp/**/*.py"] - -[tool.ruff.lint] -select = ["C4", "E", "F", "I", "PERF", "UP"] -ignore = ["PERF203"] +# The FastAPI style of using decorators in tests gives a `reportUnusedFunction` error. +# See https://github.com/microsoft/pyright/issues/7771 for more details. +# TODO(Marcelo): We should remove `reportPrivateUsage = false`. The idea is that we should test the workflow that uses +# those private functions instead of testing the private functions directly. It makes it easier to maintain the code source +# and refactor code that is not public. +executionEnvironments = [ + { root = "tests", extraPaths = ["."], reportUnusedFunction = false, reportPrivateUsage = false }, + { root = "examples/servers", reportUnusedFunction = false }, +] [tool.ruff] -line-length = 88 +line-length = 120 target-version = "py310" +extend-exclude = ["README.md"] + +[tool.ruff.lint] +select = [ + "C4", # flake8-comprehensions + "C90", # mccabe + "E", # pycodestyle + "F", # pyflakes + "I", # isort + "PERF", # Perflint + "PL", # Pylint + "UP", # pyupgrade +] +ignore = ["PERF203", "PLC0415", "PLR0402"] +mccabe.max-complexity = 24 # Default is 10 [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] "tests/server/fastmcp/test_func_metadata.py" = ["E501"] +"tests/shared/test_progress_notifications.py" = ["PLW0603"] + +[tool.ruff.lint.pylint] +allow-magic-value-types = ["bytes", "float", "int", "str"] +max-args = 23 # Default is 5 +max-branches = 23 # Default is 12 +max-returns = 13 # Default is 6 +max-statements = 102 # Default is 50 [tool.uv.workspace] -members = ["examples/servers/*"] +members = ["examples/clients/*", "examples/servers/*", "examples/snippets"] [tool.uv.sources] mcp = { workspace = true } [tool.pytest.ini_options] +log_cli = true xfail_strict = true +addopts = """ + --color=yes + --capture=fd + --numprocesses auto +""" filterwarnings = [ "error", # This should be fixed on Uvicorn's side. "ignore::DeprecationWarning:websockets", "ignore:websockets.server.WebSocketServerProtocol is deprecated:DeprecationWarning", - "ignore:Returning str or bytes.*:DeprecationWarning:mcp.server.lowlevel" + "ignore:Returning str or bytes.*:DeprecationWarning:mcp.server.lowlevel", + # pywin32 internal deprecation warning + "ignore:getargs.*The 'u' format is deprecated:DeprecationWarning", +] + +[tool.markdown.lint] +default = true +MD004 = false # ul-style - Unordered list style +MD007.indent = 2 # ul-indent - Unordered list indentation +MD013 = false # line-length - Line length +MD029 = false # ol-prefix - Ordered list item prefix +MD033 = false # no-inline-html Inline HTML +MD041 = false # first-line-heading/first-line-h1 +MD046 = false # indented-code-blocks +MD059 = false # descriptive-link-text + +# https://coverage.readthedocs.io/en/latest/config.html#run +[tool.coverage.run] +branch = true +patch = ["subprocess"] +concurrency = ["multiprocessing", "thread"] +source = ["src", "tests"] +relative_files = true +omit = [ + "src/mcp/client/__main__.py", + "src/mcp/server/__main__.py", + "src/mcp/os/posix/utilities.py", + "src/mcp/os/win32/utilities.py", +] + +# https://coverage.readthedocs.io/en/latest/config.html#report +[tool.coverage.report] +fail_under = 100 +skip_covered = true +show_missing = true +ignore_errors = true +precision = 2 +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "@overload", + "raise NotImplementedError", + "^\\s*\\.\\.\\.\\s*$", +] + +# https://coverage.readthedocs.io/en/latest/config.html#paths +[tool.coverage.paths] +source = [ + "src/", + "/home/runner/work/python-sdk/python-sdk/src/", + 'D:\a\python-sdk\python-sdk\src', ] diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000000..0d08e47b1b --- /dev/null +++ b/scripts/test @@ -0,0 +1,7 @@ +#!/bin/sh + +set -ex + +uv run --frozen coverage run -m pytest -n auto $@ +uv run --frozen coverage combine +uv run --frozen coverage report diff --git a/scripts/update_readme_snippets.py b/scripts/update_readme_snippets.py new file mode 100755 index 0000000000..d325333fff --- /dev/null +++ b/scripts/update_readme_snippets.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +Update README.md with live code snippets from example files. + +This script finds specially marked code blocks in README.md and updates them +with the actual code from the referenced files. + +Usage: + python scripts/update_readme_snippets.py + python scripts/update_readme_snippets.py --check # Check mode for CI +""" + +import argparse +import re +import sys +from pathlib import Path + + +def get_github_url(file_path: str) -> str: + """Generate a GitHub URL for the file. + + Args: + file_path: Path to the file relative to repo root + + Returns: + GitHub URL + """ + base_url = "https://github.com/modelcontextprotocol/python-sdk/blob/main" + return f"{base_url}/{file_path}" + + +def process_snippet_block(match: re.Match[str], check_mode: bool = False) -> str: + """Process a single snippet-source block. + + Args: + match: The regex match object + check_mode: If True, return original if no changes needed + + Returns: + The updated block content + """ + full_match = match.group(0) + indent = match.group(1) + file_path = match.group(2) + + try: + # Read the entire file + file = Path(file_path) + if not file.exists(): + print(f"Warning: File not found: {file_path}") + return full_match + + code = file.read_text().rstrip() + github_url = get_github_url(file_path) + + # Build the replacement block + indented_code = code.replace("\n", f"\n{indent}") + replacement = f"""{indent} +{indent}```python +{indent}{indented_code} +{indent}``` + +{indent}_Full example: [{file_path}]({github_url})_ +{indent}""" + + # In check mode, only check if code has changed + if check_mode: + # Extract existing code from the match + existing_content = match.group(3) + if existing_content is not None: + existing_lines = existing_content.strip().split("\n") + # Find code between ```python and ``` + code_lines = [] + in_code = False + for line in existing_lines: + if line.strip() == "```python": + in_code = True + elif line.strip() == "```": + break + elif in_code: + code_lines.append(line) + existing_code = "\n".join(code_lines).strip() + # Compare with the indented version we would generate + expected_code = code.replace("\n", f"\n{indent}").strip() + if existing_code == expected_code: + return full_match + + return replacement + + except Exception as e: + print(f"Error processing {file_path}: {e}") + return full_match + + +def update_readme_snippets(readme_path: Path = Path("README.md"), check_mode: bool = False) -> bool: + """Update code snippets in README.md with live code from source files. + + Args: + readme_path: Path to the README file + check_mode: If True, only check if updates are needed without modifying + + Returns: + True if file is up to date or was updated, False if check failed + """ + if not readme_path.exists(): + print(f"Error: README file not found: {readme_path}") + return False + + content = readme_path.read_text() + original_content = content + + # Pattern to match snippet-source blocks + # Matches: + # ... any content ... + # + pattern = r"^(\s*)\n" r"(.*?)" r"^\1" + + # Process all snippet-source blocks + updated_content = re.sub( + pattern, lambda m: process_snippet_block(m, check_mode), content, flags=re.MULTILINE | re.DOTALL + ) + + if check_mode: + if updated_content != original_content: + print( + f"Error: {readme_path} has outdated code snippets. " + "Run 'python scripts/update_readme_snippets.py' to update." + ) + return False + else: + print(f"✓ {readme_path} code snippets are up to date") + return True + else: + if updated_content != original_content: + readme_path.write_text(updated_content) + print(f"✓ Updated {readme_path}") + else: + print(f"✓ {readme_path} already up to date") + return True + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser(description="Update README code snippets from source files") + parser.add_argument( + "--check", action="store_true", help="Check mode - verify snippets are up to date without modifying" + ) + parser.add_argument("--readme", default="README.md", help="Path to README file (default: README.md)") + + args = parser.parse_args() + + success = update_readme_snippets(Path(args.readme), check_mode=args.check) + + if not success: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py index 0d3c372ce7..fbec40d0a9 100644 --- a/src/mcp/__init__.py +++ b/src/mcp/__init__.py @@ -1,8 +1,9 @@ from .client.session import ClientSession +from .client.session_group import ClientSessionGroup from .client.stdio import StdioServerParameters, stdio_client from .server.session import ServerSession from .server.stdio import stdio_server -from .shared.exceptions import McpError +from .shared.exceptions import McpError, UrlElicitationRequiredError from .types import ( CallToolRequest, ClientCapabilities, @@ -12,6 +13,7 @@ CompleteRequest, CreateMessageRequest, CreateMessageResult, + CreateMessageResultWithTools, ErrorData, GetPromptRequest, GetPromptResult, @@ -40,7 +42,12 @@ ResourcesCapability, ResourceUpdatedNotification, RootsCapability, + SamplingCapability, + SamplingContent, + SamplingContextCapability, SamplingMessage, + SamplingMessageContentBlock, + SamplingToolsCapability, ServerCapabilities, ServerNotification, ServerRequest, @@ -49,7 +56,10 @@ StopReason, SubscribeRequest, Tool, + ToolChoice, + ToolResultContent, ToolsCapability, + ToolUseContent, UnsubscribeRequest, ) from .types import ( @@ -63,8 +73,11 @@ "ClientRequest", "ClientResult", "ClientSession", + "ClientSessionGroup", + "CompleteRequest", "CreateMessageRequest", "CreateMessageResult", + "CreateMessageResultWithTools", "ErrorData", "GetPromptRequest", "GetPromptResult", @@ -75,6 +88,7 @@ "InitializedNotification", "JSONRPCError", "JSONRPCRequest", + "JSONRPCResponse", "ListPromptsRequest", "ListPromptsResult", "ListResourcesRequest", @@ -89,12 +103,17 @@ "PromptsCapability", "ReadResourceRequest", "ReadResourceResult", + "Resource", "ResourcesCapability", "ResourceUpdatedNotification", - "Resource", "RootsCapability", + "SamplingCapability", + "SamplingContent", + "SamplingContextCapability", "SamplingMessage", + "SamplingMessageContentBlock", "SamplingRole", + "SamplingToolsCapability", "ServerCapabilities", "ServerNotification", "ServerRequest", @@ -105,10 +124,12 @@ "StopReason", "SubscribeRequest", "Tool", + "ToolChoice", + "ToolResultContent", "ToolsCapability", + "ToolUseContent", "UnsubscribeRequest", + "UrlElicitationRequiredError", "stdio_client", "stdio_server", - "CompleteRequest", - "JSONRPCResponse", ] diff --git a/src/mcp/cli/__init__.py b/src/mcp/cli/__init__.py index 3ef56d8063..b29bce8878 100644 --- a/src/mcp/cli/__init__.py +++ b/src/mcp/cli/__init__.py @@ -2,5 +2,5 @@ from .cli import app -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover app() diff --git a/src/mcp/cli/claude.py b/src/mcp/cli/claude.py index 5a0ce0ab4f..f2dc6888a1 100644 --- a/src/mcp/cli/claude.py +++ b/src/mcp/cli/claude.py @@ -2,6 +2,7 @@ import json import os +import shutil import sys from pathlib import Path from typing import Any @@ -13,16 +14,14 @@ MCP_PACKAGE = "mcp[cli]" -def get_claude_config_path() -> Path | None: +def get_claude_config_path() -> Path | None: # pragma: no cover """Get the Claude config directory based on platform.""" if sys.platform == "win32": path = Path(Path.home(), "AppData", "Roaming", "Claude") elif sys.platform == "darwin": path = Path(Path.home(), "Library", "Application Support", "Claude") elif sys.platform.startswith("linux"): - path = Path( - os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "Claude" - ) + path = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "Claude") else: return None @@ -31,6 +30,17 @@ def get_claude_config_path() -> Path | None: return None +def get_uv_path() -> str: + """Get the full path to the uv executable.""" + uv_path = shutil.which("uv") + if not uv_path: # pragma: no cover + logger.error( + "uv executable not found in PATH, falling back to 'uv'. Please ensure uv is installed and in your PATH" + ) + return "uv" # Fall back to just "uv" if not found + return uv_path + + def update_claude_config( file_spec: str, server_name: str, @@ -54,21 +64,21 @@ def update_claude_config( Claude Desktop may not be installed or properly set up. """ config_dir = get_claude_config_path() - if not config_dir: + uv_path = get_uv_path() + if not config_dir: # pragma: no cover raise RuntimeError( "Claude Desktop config directory not found. Please ensure Claude Desktop" " is installed and has been run at least once to initialize its config." ) config_file = config_dir / "claude_desktop_config.json" - if not config_file.exists(): + if not config_file.exists(): # pragma: no cover try: config_file.write_text("{}") - except Exception as e: - logger.error( + except Exception: + logger.exception( "Failed to create Claude config file", extra={ - "error": str(e), "config_file": str(config_file), }, ) @@ -80,10 +90,7 @@ def update_claude_config( config["mcpServers"] = {} # Always preserve existing env vars and merge with new ones - if ( - server_name in config["mcpServers"] - and "env" in config["mcpServers"][server_name] - ): + if server_name in config["mcpServers"] and "env" in config["mcpServers"][server_name]: # pragma: no cover existing_env = config["mcpServers"][server_name]["env"] if env_vars: # New vars take precedence over existing ones @@ -92,18 +99,18 @@ def update_claude_config( env_vars = existing_env # Build uv run command - args = ["run"] + args = ["run", "--frozen"] # Collect all packages in a set to deduplicate packages = {MCP_PACKAGE} - if with_packages: + if with_packages: # pragma: no cover packages.update(pkg for pkg in with_packages if pkg) # Add all packages with --with for pkg in sorted(packages): args.extend(["--with", pkg]) - if with_editable: + if with_editable: # pragma: no cover args.extend(["--with-editable", str(with_editable)]) # Convert file path to absolute before adding to command @@ -111,16 +118,16 @@ def update_claude_config( if ":" in file_spec: file_path, server_object = file_spec.rsplit(":", 1) file_spec = f"{Path(file_path).resolve()}:{server_object}" - else: + else: # pragma: no cover file_spec = str(Path(file_spec).resolve()) # Add fastmcp run command args.extend(["mcp", "run", file_spec]) - server_config: dict[str, Any] = {"command": "uv", "args": args} + server_config: dict[str, Any] = {"command": uv_path, "args": args} # Add environment variables if specified - if env_vars: + if env_vars: # pragma: no cover server_config["env"] = env_vars config["mcpServers"][server_name] = server_config @@ -131,11 +138,10 @@ def update_claude_config( extra={"config_file": str(config_file)}, ) return True - except Exception as e: - logger.error( + except Exception: # pragma: no cover + logger.exception( "Failed to update Claude config", extra={ - "error": str(e), "config_file": str(config_file), }, ) diff --git a/src/mcp/cli/cli.py b/src/mcp/cli/cli.py index cb08306005..c4cae0dce3 100644 --- a/src/mcp/cli/cli.py +++ b/src/mcp/cli/cli.py @@ -6,24 +6,27 @@ import subprocess import sys from pathlib import Path -from typing import Annotated +from typing import Annotated, Any + +from mcp.server import FastMCP +from mcp.server import Server as LowLevelServer try: import typer -except ImportError: +except ImportError: # pragma: no cover print("Error: typer is required. Install with 'pip install mcp[cli]'") sys.exit(1) try: from mcp.cli import claude from mcp.server.fastmcp.utilities.logging import get_logger -except ImportError: +except ImportError: # pragma: no cover print("Error: mcp.server.fastmcp is not installed or not in PYTHONPATH") sys.exit(1) try: import dotenv -except ImportError: +except ImportError: # pragma: no cover dotenv = None logger = get_logger("cli") @@ -42,9 +45,7 @@ def _get_npx_command(): # Try both npx.cmd and npx.exe on Windows for cmd in ["npx.cmd", "npx.exe", "npx"]: try: - subprocess.run( - [cmd, "--version"], check=True, capture_output=True, shell=True - ) + subprocess.run([cmd, "--version"], check=True, capture_output=True, shell=True) return cmd except subprocess.CalledProcessError: continue @@ -52,12 +53,10 @@ def _get_npx_command(): return "npx" # On Unix-like systems, just use npx -def _parse_env_var(env_var: str) -> tuple[str, str]: +def _parse_env_var(env_var: str) -> tuple[str, str]: # pragma: no cover """Parse environment variable string in format KEY=VALUE.""" if "=" not in env_var: - logger.error( - f"Invalid environment variable format: {env_var}. Must be KEY=VALUE" - ) + logger.error(f"Invalid environment variable format: {env_var}. Must be KEY=VALUE") sys.exit(1) key, value = env_var.split("=", 1) return key.strip(), value.strip() @@ -68,7 +67,7 @@ def _build_uv_command( with_editable: Path | None = None, with_packages: list[str] | None = None, ) -> list[str]: - """Build the uv run command that runs a MCP server through mcp run.""" + """Build the uv run command that runs an MCP server through mcp run.""" cmd = ["uv"] cmd.extend(["run", "--with", "mcp"]) @@ -78,7 +77,7 @@ def _build_uv_command( if with_packages: for pkg in with_packages: - if pkg: + if pkg: # pragma: no cover cmd.extend(["--with", pkg]) # Add mcp run command @@ -117,8 +116,8 @@ def _parse_file_path(file_spec: str) -> tuple[Path, str | None]: return file_path, server_object -def _import_server(file: Path, server_object: str | None = None): - """Import a MCP server from a file. +def _import_server(file: Path, server_object: str | None = None): # pragma: no cover + """Import an MCP server from a file. Args: file: Path to the file @@ -141,17 +140,41 @@ def _import_server(file: Path, server_object: str | None = None): module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) + def _check_server_object(server_object: Any, object_name: str): + """Helper function to check that the server object is supported + + Args: + server_object: The server object to check. + + Returns: + True if it's supported. + """ + if not isinstance(server_object, FastMCP): + logger.error(f"The server object {object_name} is of type {type(server_object)} (expecting {FastMCP}).") + if isinstance(server_object, LowLevelServer): + logger.warning( + "Note that only FastMCP server is supported. Low level Server class is not yet supported." + ) + return False + return True + # If no object specified, try common server names if not server_object: # Look for the most common server object names for name in ["mcp", "server", "app"]: if hasattr(module, name): + if not _check_server_object(getattr(module, name), f"{file}:{name}"): + logger.error(f"Ignoring object '{file}:{name}' as it's not a valid server object") + continue return getattr(module, name) logger.error( f"No server object found in {file}. Please either:\n" "1. Use a standard variable name (mcp, server, or app)\n" - "2. Specify the object name with file:object syntax", + "2. Specify the object name with file:object syntax" + "3. If the server creates the FastMCP object within main() " + " or another function, refactor the FastMCP object to be a " + " global variable named mcp, server, or app.", extra={"file": str(file)}, ) sys.exit(1) @@ -179,11 +202,14 @@ def _import_server(file: Path, server_object: str | None = None): ) sys.exit(1) + if not _check_server_object(server, server_object): + sys.exit(1) + return server @app.command() -def version() -> None: +def version() -> None: # pragma: no cover """Show the MCP version.""" try: version = importlib.metadata.version("mcp") @@ -217,8 +243,8 @@ def dev( help="Additional packages to install", ), ] = [], -) -> None: - """Run a MCP server with the MCP Inspector.""" +) -> None: # pragma: no cover + """Run an MCP server with the MCP Inspector.""" file, server_object = _parse_file_path(file_spec) logger.debug( @@ -243,8 +269,7 @@ def dev( npx_cmd = _get_npx_command() if not npx_cmd: logger.error( - "npx not found. Please ensure Node.js and npm are properly installed " - "and added to your system PATH." + "npx not found. Please ensure Node.js and npm are properly installed and added to your system PATH." ) sys.exit(1) @@ -291,8 +316,8 @@ def run( help="Transport protocol to use (stdio or sse)", ), ] = None, -) -> None: - """Run a MCP server. +) -> None: # pragma: no cover + """Run an MCP server. The server can be specified in two ways:\n 1. Module approach: server.py - runs the module directly, expecting a server.run() call.\n @@ -324,12 +349,11 @@ def run( server.run(**kwargs) - except Exception as e: - logger.error( - f"Failed to run server: {e}", + except Exception: + logger.exception( + "Failed to run server", extra={ "file": str(file), - "error": str(e), }, ) sys.exit(1) @@ -346,8 +370,7 @@ def install( typer.Option( "--name", "-n", - help="Custom name for the server (defaults to server's name attribute or" - " file name)", + help="Custom name for the server (defaults to server's name attribute or file name)", ), ] = None, with_editable: Annotated[ @@ -388,8 +411,8 @@ def install( resolve_path=True, ), ] = None, -) -> None: - """Install a MCP server in the Claude desktop app. +) -> None: # pragma: no cover + """Install an MCP server in the Claude desktop app. Environment variables are preserved once added and only updated if new values are explicitly provided. @@ -421,8 +444,7 @@ def install( name = server.name except (ImportError, ModuleNotFoundError) as e: logger.debug( - "Could not import server (likely missing dependencies), using file" - " name", + "Could not import server (likely missing dependencies), using file name", extra={"error": str(e)}, ) name = file.stem @@ -440,13 +462,9 @@ def install( if env_file: if dotenv: try: - env_dict |= { - k: v - for k, v in dotenv.dotenv_values(env_file).items() - if v is not None - } - except Exception as e: - logger.error(f"Failed to load .env file: {e}") + env_dict |= {k: v for k, v in dotenv.dotenv_values(env_file).items() if v is not None} + except (OSError, ValueError): + logger.exception("Failed to load .env file") sys.exit(1) else: logger.error("python-dotenv is not installed. Cannot load .env file.") diff --git a/src/mcp/client/__main__.py b/src/mcp/client/__main__.py index 84e15bd564..2efe05d536 100644 --- a/src/mcp/client/__main__.py +++ b/src/mcp/client/__main__.py @@ -11,8 +11,8 @@ from mcp.client.session import ClientSession from mcp.client.sse import sse_client from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder -from mcp.types import JSONRPCMessage if not sys.warnoptions: import warnings @@ -24,9 +24,7 @@ async def message_handler( - message: RequestResponder[types.ServerRequest, types.ClientResult] - | types.ServerNotification - | Exception, + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, ) -> None: if isinstance(message, Exception): logger.error("Error: %s", message) @@ -36,8 +34,8 @@ async def message_handler( async def run_session( - read_stream: MemoryObjectReceiveStream[JSONRPCMessage | Exception], - write_stream: MemoryObjectSendStream[JSONRPCMessage], + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], + write_stream: MemoryObjectSendStream[SessionMessage], client_info: types.Implementation | None = None, ): async with ClientSession( @@ -60,9 +58,7 @@ async def main(command_or_url: str, args: list[str], env: list[tuple[str, str]]) await run_session(*streams) else: # Use stdio client for commands - server_parameters = StdioServerParameters( - command=command_or_url, args=args, env=env_dict - ) + server_parameters = StdioServerParameters(command=command_or_url, args=args, env=env_dict) async with stdio_client(server_parameters) as streams: await run_session(*streams) diff --git a/src/mcp/client/auth/__init__.py b/src/mcp/client/auth/__init__.py new file mode 100644 index 0000000000..252dfd9e4c --- /dev/null +++ b/src/mcp/client/auth/__init__.py @@ -0,0 +1,21 @@ +""" +OAuth2 Authentication implementation for HTTPX. + +Implements authorization code flow with PKCE and automatic token refresh. +""" + +from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError +from mcp.client.auth.oauth2 import ( + OAuthClientProvider, + PKCEParameters, + TokenStorage, +) + +__all__ = [ + "OAuthClientProvider", + "OAuthFlowError", + "OAuthRegistrationError", + "OAuthTokenError", + "PKCEParameters", + "TokenStorage", +] diff --git a/src/mcp/client/auth/exceptions.py b/src/mcp/client/auth/exceptions.py new file mode 100644 index 0000000000..5ce8777b86 --- /dev/null +++ b/src/mcp/client/auth/exceptions.py @@ -0,0 +1,10 @@ +class OAuthFlowError(Exception): + """Base exception for OAuth flow errors.""" + + +class OAuthTokenError(OAuthFlowError): + """Raised when token operations fail.""" + + +class OAuthRegistrationError(OAuthFlowError): + """Raised when client registration fails.""" diff --git a/src/mcp/client/auth/extensions/__init__.py b/src/mcp/client/auth/extensions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py new file mode 100644 index 0000000000..e2f3f08a4d --- /dev/null +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -0,0 +1,487 @@ +""" +OAuth client credential extensions for MCP. + +Provides OAuth providers for machine-to-machine authentication flows: +- ClientCredentialsOAuthProvider: For client_credentials with client_id + client_secret +- PrivateKeyJWTOAuthProvider: For client_credentials with private_key_jwt authentication + (typically using a pre-built JWT from workload identity federation) +- RFC7523OAuthClientProvider: For jwt-bearer grant (RFC 7523 Section 2.1) +""" + +import time +from collections.abc import Awaitable, Callable +from typing import Any, Literal +from uuid import uuid4 + +import httpx +import jwt +from pydantic import BaseModel, Field + +from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthTokenError, TokenStorage +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata + + +class ClientCredentialsOAuthProvider(OAuthClientProvider): + """OAuth provider for client_credentials grant with client_id + client_secret. + + This provider sets client_info directly, bypassing dynamic client registration. + Use this when you already have client credentials (client_id and client_secret). + + Example: + ```python + provider = ClientCredentialsOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + client_secret="my-client-secret", + ) + ``` + """ + + def __init__( + self, + server_url: str, + storage: TokenStorage, + client_id: str, + client_secret: str, + token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic", + scopes: str | None = None, + ) -> None: + """Initialize client_credentials OAuth provider. + + Args: + server_url: The MCP server URL. + storage: Token storage implementation. + client_id: The OAuth client ID. + client_secret: The OAuth client secret. + token_endpoint_auth_method: Authentication method for token endpoint. + Either "client_secret_basic" (default) or "client_secret_post". + scopes: Optional space-separated list of scopes to request. + """ + # Build minimal client_metadata for the base class + client_metadata = OAuthClientMetadata( + redirect_uris=None, + grant_types=["client_credentials"], + token_endpoint_auth_method=token_endpoint_auth_method, + scope=scopes, + ) + super().__init__(server_url, client_metadata, storage, None, None, 300.0) + # Store client_info to be set during _initialize - no dynamic registration needed + self._fixed_client_info = OAuthClientInformationFull( + redirect_uris=None, + client_id=client_id, + client_secret=client_secret, + grant_types=["client_credentials"], + token_endpoint_auth_method=token_endpoint_auth_method, + scope=scopes, + ) + + async def _initialize(self) -> None: + """Load stored tokens and set pre-configured client_info.""" + self.context.current_tokens = await self.context.storage.get_tokens() + self.context.client_info = self._fixed_client_info + self._initialized = True + + async def _perform_authorization(self) -> httpx.Request: + """Perform client_credentials authorization.""" + return await self._exchange_token_client_credentials() + + async def _exchange_token_client_credentials(self) -> httpx.Request: + """Build token exchange request for client_credentials grant.""" + token_data: dict[str, Any] = { + "grant_type": "client_credentials", + } + + headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"} + + # Use standard auth methods (client_secret_basic, client_secret_post, none) + token_data, headers = self.context.prepare_token_auth(token_data, headers) + + if self.context.should_include_resource_param(self.context.protocol_version): + token_data["resource"] = self.context.get_resource_url() + + if self.context.client_metadata.scope: + token_data["scope"] = self.context.client_metadata.scope + + token_url = self._get_token_endpoint() + return httpx.Request("POST", token_url, data=token_data, headers=headers) + + +def static_assertion_provider(token: str) -> Callable[[str], Awaitable[str]]: + """Create an assertion provider that returns a static JWT token. + + Use this when you have a pre-built JWT (e.g., from workload identity federation) + that doesn't need the audience parameter. + + Example: + ```python + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + assertion_provider=static_assertion_provider(my_prebuilt_jwt), + ) + ``` + + Args: + token: The pre-built JWT assertion string. + + Returns: + An async callback suitable for use as an assertion_provider. + """ + + async def provider(audience: str) -> str: + return token + + return provider + + +class SignedJWTParameters(BaseModel): + """Parameters for creating SDK-signed JWT assertions. + + Use `create_assertion_provider()` to create an assertion provider callback + for use with `PrivateKeyJWTOAuthProvider`. + + Example: + ```python + jwt_params = SignedJWTParameters( + issuer="my-client-id", + subject="my-client-id", + signing_key=private_key_pem, + ) + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + assertion_provider=jwt_params.create_assertion_provider(), + ) + ``` + """ + + issuer: str = Field(description="Issuer for JWT assertions (typically client_id).") + subject: str = Field(description="Subject identifier for JWT assertions (typically client_id).") + signing_key: str = Field(description="Private key for JWT signing (PEM format).") + signing_algorithm: str = Field(default="RS256", description="Algorithm for signing JWT assertions.") + lifetime_seconds: int = Field(default=300, description="Lifetime of generated JWT in seconds.") + additional_claims: dict[str, Any] | None = Field(default=None, description="Additional claims.") + + def create_assertion_provider(self) -> Callable[[str], Awaitable[str]]: + """Create an assertion provider callback for use with PrivateKeyJWTOAuthProvider. + + Returns: + An async callback that takes the audience (authorization server issuer URL) + and returns a signed JWT assertion. + """ + + async def provider(audience: str) -> str: + now = int(time.time()) + claims: dict[str, Any] = { + "iss": self.issuer, + "sub": self.subject, + "aud": audience, + "exp": now + self.lifetime_seconds, + "iat": now, + "jti": str(uuid4()), + } + if self.additional_claims: + claims.update(self.additional_claims) + + return jwt.encode(claims, self.signing_key, algorithm=self.signing_algorithm) + + return provider + + +class PrivateKeyJWTOAuthProvider(OAuthClientProvider): + """OAuth provider for client_credentials grant with private_key_jwt authentication. + + Uses RFC 7523 Section 2.2 for client authentication via JWT assertion. + + The JWT assertion's audience MUST be the authorization server's issuer identifier + (per RFC 7523bis security updates). The `assertion_provider` callback receives + this audience value and must return a JWT with that audience. + + **Option 1: Pre-built JWT via Workload Identity Federation** + + In production scenarios, the JWT assertion is typically obtained from a workload + identity provider (e.g., GCP, AWS IAM, Azure AD): + + ```python + async def get_workload_identity_token(audience: str) -> str: + # Fetch JWT from your identity provider + # The JWT's audience must match the provided audience parameter + return await fetch_token_from_identity_provider(audience=audience) + + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + assertion_provider=get_workload_identity_token, + ) + ``` + + **Option 2: Static pre-built JWT** + + If you have a static JWT that doesn't need the audience parameter: + + ```python + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + assertion_provider=static_assertion_provider(my_prebuilt_jwt), + ) + ``` + + **Option 3: SDK-signed JWT (for testing/simple setups)** + + For testing or simple deployments, use `SignedJWTParameters.create_assertion_provider()`: + + ```python + jwt_params = SignedJWTParameters( + issuer="my-client-id", + subject="my-client-id", + signing_key=private_key_pem, + ) + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + assertion_provider=jwt_params.create_assertion_provider(), + ) + ``` + """ + + def __init__( + self, + server_url: str, + storage: TokenStorage, + client_id: str, + assertion_provider: Callable[[str], Awaitable[str]], + scopes: str | None = None, + ) -> None: + """Initialize private_key_jwt OAuth provider. + + Args: + server_url: The MCP server URL. + storage: Token storage implementation. + client_id: The OAuth client ID. + assertion_provider: Async callback that takes the audience (authorization + server's issuer identifier) and returns a JWT assertion. Use + `SignedJWTParameters.create_assertion_provider()` for SDK-signed JWTs, + `static_assertion_provider()` for pre-built JWTs, or provide your own + callback for workload identity federation. + scopes: Optional space-separated list of scopes to request. + """ + # Build minimal client_metadata for the base class + client_metadata = OAuthClientMetadata( + redirect_uris=None, + grant_types=["client_credentials"], + token_endpoint_auth_method="private_key_jwt", + scope=scopes, + ) + super().__init__(server_url, client_metadata, storage, None, None, 300.0) + self._assertion_provider = assertion_provider + # Store client_info to be set during _initialize - no dynamic registration needed + self._fixed_client_info = OAuthClientInformationFull( + redirect_uris=None, + client_id=client_id, + grant_types=["client_credentials"], + token_endpoint_auth_method="private_key_jwt", + scope=scopes, + ) + + async def _initialize(self) -> None: + """Load stored tokens and set pre-configured client_info.""" + self.context.current_tokens = await self.context.storage.get_tokens() + self.context.client_info = self._fixed_client_info + self._initialized = True + + async def _perform_authorization(self) -> httpx.Request: + """Perform client_credentials authorization with private_key_jwt.""" + return await self._exchange_token_client_credentials() + + async def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]) -> None: + """Add JWT assertion for client authentication to token endpoint parameters.""" + if not self.context.oauth_metadata: + raise OAuthFlowError("Missing OAuth metadata for private_key_jwt flow") # pragma: no cover + + # Audience MUST be the issuer identifier of the authorization server + # https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01 + audience = str(self.context.oauth_metadata.issuer) + assertion = await self._assertion_provider(audience) + + # RFC 7523 Section 2.2: client authentication via JWT + token_data["client_assertion"] = assertion + token_data["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + + async def _exchange_token_client_credentials(self) -> httpx.Request: + """Build token exchange request for client_credentials grant with private_key_jwt.""" + token_data: dict[str, Any] = { + "grant_type": "client_credentials", + } + + headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"} + + # Add JWT client authentication (RFC 7523 Section 2.2) + await self._add_client_authentication_jwt(token_data=token_data) + + if self.context.should_include_resource_param(self.context.protocol_version): + token_data["resource"] = self.context.get_resource_url() + + if self.context.client_metadata.scope: + token_data["scope"] = self.context.client_metadata.scope + + token_url = self._get_token_endpoint() + return httpx.Request("POST", token_url, data=token_data, headers=headers) + + +class JWTParameters(BaseModel): + """JWT parameters.""" + + assertion: str | None = Field( + default=None, + description="JWT assertion for JWT authentication. " + "Will be used instead of generating a new assertion if provided.", + ) + + issuer: str | None = Field(default=None, description="Issuer for JWT assertions.") + subject: str | None = Field(default=None, description="Subject identifier for JWT assertions.") + audience: str | None = Field(default=None, description="Audience for JWT assertions.") + claims: dict[str, Any] | None = Field(default=None, description="Additional claims for JWT assertions.") + jwt_signing_algorithm: str | None = Field(default="RS256", description="Algorithm for signing JWT assertions.") + jwt_signing_key: str | None = Field(default=None, description="Private key for JWT signing.") + jwt_lifetime_seconds: int = Field(default=300, description="Lifetime of generated JWT in seconds.") + + def to_assertion(self, with_audience_fallback: str | None = None) -> str: + if self.assertion is not None: + # Prebuilt JWT (e.g. acquired out-of-band) + assertion = self.assertion + else: + if not self.jwt_signing_key: + raise OAuthFlowError("Missing signing key for JWT bearer grant") # pragma: no cover + if not self.issuer: + raise OAuthFlowError("Missing issuer for JWT bearer grant") # pragma: no cover + if not self.subject: + raise OAuthFlowError("Missing subject for JWT bearer grant") # pragma: no cover + + audience = self.audience if self.audience else with_audience_fallback + if not audience: + raise OAuthFlowError("Missing audience for JWT bearer grant") # pragma: no cover + + now = int(time.time()) + claims: dict[str, Any] = { + "iss": self.issuer, + "sub": self.subject, + "aud": audience, + "exp": now + self.jwt_lifetime_seconds, + "iat": now, + "jti": str(uuid4()), + } + claims.update(self.claims or {}) + + assertion = jwt.encode( + claims, + self.jwt_signing_key, + algorithm=self.jwt_signing_algorithm or "RS256", + ) + return assertion + + +class RFC7523OAuthClientProvider(OAuthClientProvider): + """OAuth client provider for RFC 7523 jwt-bearer grant. + + .. deprecated:: + Use :class:`ClientCredentialsOAuthProvider` for client_credentials with + client_id + client_secret, or :class:`PrivateKeyJWTOAuthProvider` for + client_credentials with private_key_jwt authentication instead. + + This provider supports the jwt-bearer authorization grant (RFC 7523 Section 2.1) + where the JWT itself is the authorization grant. + """ + + def __init__( + self, + server_url: str, + client_metadata: OAuthClientMetadata, + storage: TokenStorage, + redirect_handler: Callable[[str], Awaitable[None]] | None = None, + callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None, + timeout: float = 300.0, + jwt_parameters: JWTParameters | None = None, + ) -> None: + import warnings + + warnings.warn( + "RFC7523OAuthClientProvider is deprecated. Use ClientCredentialsOAuthProvider " + "or PrivateKeyJWTOAuthProvider instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(server_url, client_metadata, storage, redirect_handler, callback_handler, timeout) + self.jwt_parameters = jwt_parameters + + async def _exchange_token_authorization_code( + self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = None + ) -> httpx.Request: # pragma: no cover + """Build token exchange request for authorization_code flow.""" + token_data = token_data or {} + if self.context.client_metadata.token_endpoint_auth_method == "private_key_jwt": + self._add_client_authentication_jwt(token_data=token_data) + return await super()._exchange_token_authorization_code(auth_code, code_verifier, token_data=token_data) + + async def _perform_authorization(self) -> httpx.Request: # pragma: no cover + """Perform the authorization flow.""" + if "urn:ietf:params:oauth:grant-type:jwt-bearer" in self.context.client_metadata.grant_types: + token_request = await self._exchange_token_jwt_bearer() + return token_request + else: + return await super()._perform_authorization() + + def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]): # pragma: no cover + """Add JWT assertion for client authentication to token endpoint parameters.""" + if not self.jwt_parameters: + raise OAuthTokenError("Missing JWT parameters for private_key_jwt flow") + if not self.context.oauth_metadata: + raise OAuthTokenError("Missing OAuth metadata for private_key_jwt flow") + + # We need to set the audience to the issuer identifier of the authorization server + # https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01#name-updates-to-rfc-7523 + issuer = str(self.context.oauth_metadata.issuer) + assertion = self.jwt_parameters.to_assertion(with_audience_fallback=issuer) + + # When using private_key_jwt, in a client_credentials flow, we use RFC 7523 Section 2.2 + token_data["client_assertion"] = assertion + token_data["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + # We need to set the audience to the resource server, the audience is difference from the one in claims + # it represents the resource server that will validate the token + token_data["audience"] = self.context.get_resource_url() + + async def _exchange_token_jwt_bearer(self) -> httpx.Request: + """Build token exchange request for JWT bearer grant.""" + if not self.context.client_info: + raise OAuthFlowError("Missing client info") # pragma: no cover + if not self.jwt_parameters: + raise OAuthFlowError("Missing JWT parameters") # pragma: no cover + if not self.context.oauth_metadata: + raise OAuthTokenError("Missing OAuth metadata") # pragma: no cover + + # We need to set the audience to the issuer identifier of the authorization server + # https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01#name-updates-to-rfc-7523 + issuer = str(self.context.oauth_metadata.issuer) + assertion = self.jwt_parameters.to_assertion(with_audience_fallback=issuer) + + token_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + } + + if self.context.should_include_resource_param(self.context.protocol_version): # pragma: no branch + token_data["resource"] = self.context.get_resource_url() + + if self.context.client_metadata.scope: # pragma: no branch + token_data["scope"] = self.context.client_metadata.scope + + token_url = self._get_token_endpoint() + return httpx.Request( + "POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"} + ) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py new file mode 100644 index 0000000000..ddc61ef663 --- /dev/null +++ b/src/mcp/client/auth/oauth2.py @@ -0,0 +1,616 @@ +""" +OAuth2 Authentication implementation for HTTPX. + +Implements authorization code flow with PKCE and automatic token refresh. +""" + +import base64 +import hashlib +import logging +import secrets +import string +import time +from collections.abc import AsyncGenerator, Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any, Protocol +from urllib.parse import quote, urlencode, urljoin, urlparse + +import anyio +import httpx +from pydantic import BaseModel, Field, ValidationError + +from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError +from mcp.client.auth.utils import ( + build_oauth_authorization_server_metadata_discovery_urls, + build_protected_resource_metadata_discovery_urls, + create_client_info_from_metadata_url, + create_client_registration_request, + create_oauth_metadata_request, + extract_field_from_www_auth, + extract_resource_metadata_from_www_auth, + extract_scope_from_www_auth, + get_client_metadata_scopes, + handle_auth_metadata_response, + handle_protected_resource_response, + handle_registration_response, + handle_token_response_scopes, + is_valid_client_metadata_url, + should_use_client_metadata_url, +) +from mcp.client.streamable_http import MCP_PROTOCOL_VERSION +from mcp.shared.auth import ( + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthMetadata, + OAuthToken, + ProtectedResourceMetadata, +) +from mcp.shared.auth_utils import ( + calculate_token_expiry, + check_resource_allowed, + resource_url_from_server_url, +) + +logger = logging.getLogger(__name__) + + +class PKCEParameters(BaseModel): + """PKCE (Proof Key for Code Exchange) parameters.""" + + code_verifier: str = Field(..., min_length=43, max_length=128) + code_challenge: str = Field(..., min_length=43, max_length=128) + + @classmethod + def generate(cls) -> "PKCEParameters": + """Generate new PKCE parameters.""" + code_verifier = "".join(secrets.choice(string.ascii_letters + string.digits + "-._~") for _ in range(128)) + digest = hashlib.sha256(code_verifier.encode()).digest() + code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=") + return cls(code_verifier=code_verifier, code_challenge=code_challenge) + + +class TokenStorage(Protocol): + """Protocol for token storage implementations.""" + + async def get_tokens(self) -> OAuthToken | None: + """Get stored tokens.""" + ... + + async def set_tokens(self, tokens: OAuthToken) -> None: + """Store tokens.""" + ... + + async def get_client_info(self) -> OAuthClientInformationFull | None: + """Get stored client information.""" + ... + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + """Store client information.""" + ... + + +@dataclass +class OAuthContext: + """OAuth flow context.""" + + server_url: str + client_metadata: OAuthClientMetadata + storage: TokenStorage + redirect_handler: Callable[[str], Awaitable[None]] | None + callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None + timeout: float = 300.0 + client_metadata_url: str | None = None + + # Discovered metadata + protected_resource_metadata: ProtectedResourceMetadata | None = None + oauth_metadata: OAuthMetadata | None = None + auth_server_url: str | None = None + protocol_version: str | None = None + + # Client registration + client_info: OAuthClientInformationFull | None = None + + # Token management + current_tokens: OAuthToken | None = None + token_expiry_time: float | None = None + + # State + lock: anyio.Lock = field(default_factory=anyio.Lock) + + def get_authorization_base_url(self, server_url: str) -> str: + """Extract base URL by removing path component.""" + parsed = urlparse(server_url) + return f"{parsed.scheme}://{parsed.netloc}" + + def update_token_expiry(self, token: OAuthToken) -> None: + """Update token expiry time using shared util function.""" + self.token_expiry_time = calculate_token_expiry(token.expires_in) + + def is_token_valid(self) -> bool: + """Check if current token is valid.""" + return bool( + self.current_tokens + and self.current_tokens.access_token + and (not self.token_expiry_time or time.time() <= self.token_expiry_time) + ) + + def can_refresh_token(self) -> bool: + """Check if token can be refreshed.""" + return bool(self.current_tokens and self.current_tokens.refresh_token and self.client_info) + + def clear_tokens(self) -> None: + """Clear current tokens.""" + self.current_tokens = None + self.token_expiry_time = None + + def get_resource_url(self) -> str: + """Get resource URL for RFC 8707. + + Uses PRM resource if it's a valid parent, otherwise uses canonical server URL. + """ + resource = resource_url_from_server_url(self.server_url) + + # If PRM provides a resource that's a valid parent, use it + if self.protected_resource_metadata and self.protected_resource_metadata.resource: + prm_resource = str(self.protected_resource_metadata.resource) + if check_resource_allowed(requested_resource=resource, configured_resource=prm_resource): + resource = prm_resource + + return resource + + def should_include_resource_param(self, protocol_version: str | None = None) -> bool: + """Determine if the resource parameter should be included in OAuth requests. + + Returns True if: + - Protected resource metadata is available, OR + - MCP-Protocol-Version header is 2025-06-18 or later + """ + # If we have protected resource metadata, include the resource param + if self.protected_resource_metadata is not None: + return True + + # If no protocol version provided, don't include resource param + if not protocol_version: + return False + + # Check if protocol version is 2025-06-18 or later + # Version format is YYYY-MM-DD, so string comparison works + return protocol_version >= "2025-06-18" + + def prepare_token_auth( + self, data: dict[str, str], headers: dict[str, str] | None = None + ) -> tuple[dict[str, str], dict[str, str]]: + """Prepare authentication for token requests. + + Args: + data: The form data to send + headers: Optional headers dict to update + + Returns: + Tuple of (updated_data, updated_headers) + """ + if headers is None: + headers = {} # pragma: no cover + + if not self.client_info: + return data, headers # pragma: no cover + + auth_method = self.client_info.token_endpoint_auth_method + + if auth_method == "client_secret_basic" and self.client_info.client_id and self.client_info.client_secret: + # URL-encode client ID and secret per RFC 6749 Section 2.3.1 + encoded_id = quote(self.client_info.client_id, safe="") + encoded_secret = quote(self.client_info.client_secret, safe="") + credentials = f"{encoded_id}:{encoded_secret}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + headers["Authorization"] = f"Basic {encoded_credentials}" + # Don't include client_secret in body for basic auth + data = {k: v for k, v in data.items() if k != "client_secret"} + elif auth_method == "client_secret_post" and self.client_info.client_secret: + # Include client_secret in request body + data["client_secret"] = self.client_info.client_secret + # For auth_method == "none", don't add any client_secret + + return data, headers + + +class OAuthClientProvider(httpx.Auth): + """ + OAuth2 authentication for httpx. + Handles OAuth flow with automatic client registration and token storage. + """ + + requires_response_body = True + + def __init__( + self, + server_url: str, + client_metadata: OAuthClientMetadata, + storage: TokenStorage, + redirect_handler: Callable[[str], Awaitable[None]] | None = None, + callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None, + timeout: float = 300.0, + client_metadata_url: str | None = None, + ): + """Initialize OAuth2 authentication. + + Args: + server_url: The MCP server URL. + client_metadata: OAuth client metadata for registration. + storage: Token storage implementation. + redirect_handler: Handler for authorization redirects. + callback_handler: Handler for authorization callbacks. + timeout: Timeout for the OAuth flow. + client_metadata_url: URL-based client ID. When provided and the server + advertises client_id_metadata_document_supported=true, this URL will be + used as the client_id instead of performing dynamic client registration. + Must be a valid HTTPS URL with a non-root pathname. + + Raises: + ValueError: If client_metadata_url is provided but not a valid HTTPS URL + with a non-root pathname. + """ + # Validate client_metadata_url if provided + if client_metadata_url is not None and not is_valid_client_metadata_url(client_metadata_url): + raise ValueError( + f"client_metadata_url must be a valid HTTPS URL with a non-root pathname, got: {client_metadata_url}" + ) + + self.context = OAuthContext( + server_url=server_url, + client_metadata=client_metadata, + storage=storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + timeout=timeout, + client_metadata_url=client_metadata_url, + ) + self._initialized = False + + async def _handle_protected_resource_response(self, response: httpx.Response) -> bool: + """ + Handle protected resource metadata discovery response. + + Per SEP-985, supports fallback when discovery fails at one URL. + + Returns: + True if metadata was successfully discovered, False if we should try next URL + """ + if response.status_code == 200: + try: + content = await response.aread() + metadata = ProtectedResourceMetadata.model_validate_json(content) + self.context.protected_resource_metadata = metadata + if metadata.authorization_servers: # pragma: no branch + self.context.auth_server_url = str(metadata.authorization_servers[0]) + return True + + except ValidationError: # pragma: no cover + # Invalid metadata - try next URL + logger.warning(f"Invalid protected resource metadata at {response.request.url}") + return False + elif response.status_code == 404: # pragma: no cover + # Not found - try next URL in fallback chain + logger.debug(f"Protected resource metadata not found at {response.request.url}, trying next URL") + return False + else: + # Other error - fail immediately + raise OAuthFlowError( + f"Protected Resource Metadata request failed: {response.status_code}" + ) # pragma: no cover + + async def _perform_authorization(self) -> httpx.Request: + """Perform the authorization flow.""" + auth_code, code_verifier = await self._perform_authorization_code_grant() + token_request = await self._exchange_token_authorization_code(auth_code, code_verifier) + return token_request + + async def _perform_authorization_code_grant(self) -> tuple[str, str]: + """Perform the authorization redirect and get auth code.""" + if self.context.client_metadata.redirect_uris is None: + raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover + if not self.context.redirect_handler: + raise OAuthFlowError("No redirect handler provided for authorization code grant") # pragma: no cover + if not self.context.callback_handler: + raise OAuthFlowError("No callback handler provided for authorization code grant") # pragma: no cover + + if self.context.oauth_metadata and self.context.oauth_metadata.authorization_endpoint: + auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint) # pragma: no cover + else: + auth_base_url = self.context.get_authorization_base_url(self.context.server_url) + auth_endpoint = urljoin(auth_base_url, "/authorize") + + if not self.context.client_info: + raise OAuthFlowError("No client info available for authorization") # pragma: no cover + + # Generate PKCE parameters + pkce_params = PKCEParameters.generate() + state = secrets.token_urlsafe(32) + + auth_params = { + "response_type": "code", + "client_id": self.context.client_info.client_id, + "redirect_uri": str(self.context.client_metadata.redirect_uris[0]), + "state": state, + "code_challenge": pkce_params.code_challenge, + "code_challenge_method": "S256", + } + + # Only include resource param if conditions are met + if self.context.should_include_resource_param(self.context.protocol_version): + auth_params["resource"] = self.context.get_resource_url() # RFC 8707 # pragma: no cover + + if self.context.client_metadata.scope: # pragma: no branch + auth_params["scope"] = self.context.client_metadata.scope + + authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}" + await self.context.redirect_handler(authorization_url) + + # Wait for callback + auth_code, returned_state = await self.context.callback_handler() + + if returned_state is None or not secrets.compare_digest(returned_state, state): + raise OAuthFlowError(f"State parameter mismatch: {returned_state} != {state}") # pragma: no cover + + if not auth_code: + raise OAuthFlowError("No authorization code received") # pragma: no cover + + # Return auth code and code verifier for token exchange + return auth_code, pkce_params.code_verifier + + def _get_token_endpoint(self) -> str: + if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint: + token_url = str(self.context.oauth_metadata.token_endpoint) + else: + auth_base_url = self.context.get_authorization_base_url(self.context.server_url) + token_url = urljoin(auth_base_url, "/token") + return token_url + + async def _exchange_token_authorization_code( + self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = {} + ) -> httpx.Request: + """Build token exchange request for authorization_code flow.""" + if self.context.client_metadata.redirect_uris is None: + raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover + if not self.context.client_info: + raise OAuthFlowError("Missing client info") # pragma: no cover + + token_url = self._get_token_endpoint() + token_data = token_data or {} + token_data.update( + { + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": str(self.context.client_metadata.redirect_uris[0]), + "client_id": self.context.client_info.client_id, + "code_verifier": code_verifier, + } + ) + + # Only include resource param if conditions are met + if self.context.should_include_resource_param(self.context.protocol_version): + token_data["resource"] = self.context.get_resource_url() # RFC 8707 + + # Prepare authentication based on preferred method + headers = {"Content-Type": "application/x-www-form-urlencoded"} + token_data, headers = self.context.prepare_token_auth(token_data, headers) + + return httpx.Request("POST", token_url, data=token_data, headers=headers) + + async def _handle_token_response(self, response: httpx.Response) -> None: + """Handle token exchange response.""" + if response.status_code not in {200, 201}: + body = await response.aread() # pragma: no cover + body_text = body.decode("utf-8") # pragma: no cover + raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body_text}") # pragma: no cover + + # Parse and validate response with scope validation + token_response = await handle_token_response_scopes(response) + + # Store tokens in context + self.context.current_tokens = token_response + self.context.update_token_expiry(token_response) + await self.context.storage.set_tokens(token_response) + + async def _refresh_token(self) -> httpx.Request: + """Build token refresh request.""" + if not self.context.current_tokens or not self.context.current_tokens.refresh_token: + raise OAuthTokenError("No refresh token available") # pragma: no cover + + if not self.context.client_info or not self.context.client_info.client_id: + raise OAuthTokenError("No client info available") # pragma: no cover + + if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint: + token_url = str(self.context.oauth_metadata.token_endpoint) # pragma: no cover + else: + auth_base_url = self.context.get_authorization_base_url(self.context.server_url) + token_url = urljoin(auth_base_url, "/token") + + refresh_data: dict[str, str] = { + "grant_type": "refresh_token", + "refresh_token": self.context.current_tokens.refresh_token, + "client_id": self.context.client_info.client_id, + } + + # Only include resource param if conditions are met + if self.context.should_include_resource_param(self.context.protocol_version): + refresh_data["resource"] = self.context.get_resource_url() # RFC 8707 + + # Prepare authentication based on preferred method + headers = {"Content-Type": "application/x-www-form-urlencoded"} + refresh_data, headers = self.context.prepare_token_auth(refresh_data, headers) + + return httpx.Request("POST", token_url, data=refresh_data, headers=headers) + + async def _handle_refresh_response(self, response: httpx.Response) -> bool: # pragma: no cover + """Handle token refresh response. Returns True if successful.""" + if response.status_code != 200: + logger.warning(f"Token refresh failed: {response.status_code}") + self.context.clear_tokens() + return False + + try: + content = await response.aread() + token_response = OAuthToken.model_validate_json(content) + + self.context.current_tokens = token_response + self.context.update_token_expiry(token_response) + await self.context.storage.set_tokens(token_response) + + return True + except ValidationError: + logger.exception("Invalid refresh response") + self.context.clear_tokens() + return False + + async def _initialize(self) -> None: # pragma: no cover + """Load stored tokens and client info.""" + self.context.current_tokens = await self.context.storage.get_tokens() + self.context.client_info = await self.context.storage.get_client_info() + self._initialized = True + + def _add_auth_header(self, request: httpx.Request) -> None: + """Add authorization header to request if we have valid tokens.""" + if self.context.current_tokens and self.context.current_tokens.access_token: # pragma: no branch + request.headers["Authorization"] = f"Bearer {self.context.current_tokens.access_token}" + + async def _handle_oauth_metadata_response(self, response: httpx.Response) -> None: + content = await response.aread() + metadata = OAuthMetadata.model_validate_json(content) + self.context.oauth_metadata = metadata + + async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]: + """HTTPX auth flow integration.""" + async with self.context.lock: + if not self._initialized: + await self._initialize() # pragma: no cover + + # Capture protocol version from request headers + self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION) + + if not self.context.is_token_valid() and self.context.can_refresh_token(): + # Try to refresh token + refresh_request = await self._refresh_token() # pragma: no cover + refresh_response = yield refresh_request # pragma: no cover + + if not await self._handle_refresh_response(refresh_response): # pragma: no cover + # Refresh failed, need full re-authentication + self._initialized = False + + if self.context.is_token_valid(): + self._add_auth_header(request) + + response = yield request + + if response.status_code == 401: + # Perform full OAuth flow + try: + # OAuth flow must be inline due to generator constraints + www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response) + + # Step 1: Discover protected resource metadata (SEP-985 with fallback support) + prm_discovery_urls = build_protected_resource_metadata_discovery_urls( + www_auth_resource_metadata_url, self.context.server_url + ) + + for url in prm_discovery_urls: # pragma: no branch + discovery_request = create_oauth_metadata_request(url) + + discovery_response = yield discovery_request # sending request + + prm = await handle_protected_resource_response(discovery_response) + if prm: + self.context.protected_resource_metadata = prm + + # todo: try all authorization_servers to find the OASM + assert ( + len(prm.authorization_servers) > 0 + ) # this is always true as authorization_servers has a min length of 1 + + self.context.auth_server_url = str(prm.authorization_servers[0]) + break + else: + logger.debug(f"Protected resource metadata discovery failed: {url}") + + asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + self.context.auth_server_url, self.context.server_url + ) + + # Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers) + for url in asm_discovery_urls: # pragma: no cover + oauth_metadata_request = create_oauth_metadata_request(url) + oauth_metadata_response = yield oauth_metadata_request + + ok, asm = await handle_auth_metadata_response(oauth_metadata_response) + if not ok: + break + if ok and asm: + self.context.oauth_metadata = asm + break + else: + logger.debug(f"OAuth metadata discovery failed: {url}") + + # Step 3: Apply scope selection strategy + self.context.client_metadata.scope = get_client_metadata_scopes( + extract_scope_from_www_auth(response), + self.context.protected_resource_metadata, + self.context.oauth_metadata, + ) + + # Step 4: Register client or use URL-based client ID (CIMD) + if not self.context.client_info: + if should_use_client_metadata_url( + self.context.oauth_metadata, self.context.client_metadata_url + ): + # Use URL-based client ID (CIMD) + logger.debug(f"Using URL-based client ID (CIMD): {self.context.client_metadata_url}") + client_information = create_client_info_from_metadata_url( + self.context.client_metadata_url, # type: ignore[arg-type] + redirect_uris=self.context.client_metadata.redirect_uris, + ) + self.context.client_info = client_information + await self.context.storage.set_client_info(client_information) + else: + # Fallback to Dynamic Client Registration + registration_request = create_client_registration_request( + self.context.oauth_metadata, + self.context.client_metadata, + self.context.get_authorization_base_url(self.context.server_url), + ) + registration_response = yield registration_request + client_information = await handle_registration_response(registration_response) + self.context.client_info = client_information + await self.context.storage.set_client_info(client_information) + + # Step 5: Perform authorization and complete token exchange + token_response = yield await self._perform_authorization() + await self._handle_token_response(token_response) + except Exception: # pragma: no cover + logger.exception("OAuth flow error") + raise + + # Retry with new tokens + self._add_auth_header(request) + yield request + elif response.status_code == 403: + # Step 1: Extract error field from WWW-Authenticate header + error = extract_field_from_www_auth(response, "error") + + # Step 2: Check if we need to step-up authorization + if error == "insufficient_scope": # pragma: no branch + try: + # Step 2a: Update the required scopes + self.context.client_metadata.scope = get_client_metadata_scopes( + extract_scope_from_www_auth(response), self.context.protected_resource_metadata + ) + + # Step 2b: Perform (re-)authorization and token exchange + token_response = yield await self._perform_authorization() + await self._handle_token_response(token_response) + except Exception: # pragma: no cover + logger.exception("OAuth flow error") + raise + + # Retry with new tokens + self._add_auth_header(request) + yield request diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py new file mode 100644 index 0000000000..b4426be7f8 --- /dev/null +++ b/src/mcp/client/auth/utils.py @@ -0,0 +1,336 @@ +import logging +import re +from urllib.parse import urljoin, urlparse + +from httpx import Request, Response +from pydantic import AnyUrl, ValidationError + +from mcp.client.auth import OAuthRegistrationError, OAuthTokenError +from mcp.client.streamable_http import MCP_PROTOCOL_VERSION +from mcp.shared.auth import ( + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthMetadata, + OAuthToken, + ProtectedResourceMetadata, +) +from mcp.types import LATEST_PROTOCOL_VERSION + +logger = logging.getLogger(__name__) + + +def extract_field_from_www_auth(response: Response, field_name: str) -> str | None: + """ + Extract field from WWW-Authenticate header. + + Returns: + Field value if found in WWW-Authenticate header, None otherwise + """ + www_auth_header = response.headers.get("WWW-Authenticate") + if not www_auth_header: + return None + + # Pattern matches: field_name="value" or field_name=value (unquoted) + pattern = rf'{field_name}=(?:"([^"]+)"|([^\s,]+))' + match = re.search(pattern, www_auth_header) + + if match: + # Return quoted value if present, otherwise unquoted value + return match.group(1) or match.group(2) + + return None + + +def extract_scope_from_www_auth(response: Response) -> str | None: + """ + Extract scope parameter from WWW-Authenticate header as per RFC6750. + + Returns: + Scope string if found in WWW-Authenticate header, None otherwise + """ + return extract_field_from_www_auth(response, "scope") + + +def extract_resource_metadata_from_www_auth(response: Response) -> str | None: + """ + Extract protected resource metadata URL from WWW-Authenticate header as per RFC9728. + + Returns: + Resource metadata URL if found in WWW-Authenticate header, None otherwise + """ + if not response or response.status_code != 401: + return None # pragma: no cover + + return extract_field_from_www_auth(response, "resource_metadata") + + +def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, server_url: str) -> list[str]: + """ + Build ordered list of URLs to try for protected resource metadata discovery. + + Per SEP-985, the client MUST: + 1. Try resource_metadata from WWW-Authenticate header (if present) + 2. Fall back to path-based well-known URI: /.well-known/oauth-protected-resource/{path} + 3. Fall back to root-based well-known URI: /.well-known/oauth-protected-resource + + Args: + www_auth_url: optional resource_metadata url extracted from the WWW-Authenticate header + server_url: server url + + Returns: + Ordered list of URLs to try for discovery + """ + urls: list[str] = [] + + # Priority 1: WWW-Authenticate header with resource_metadata parameter + if www_auth_url: + urls.append(www_auth_url) + + # Priority 2-3: Well-known URIs (RFC 9728) + parsed = urlparse(server_url) + base_url = f"{parsed.scheme}://{parsed.netloc}" + + # Priority 2: Path-based well-known URI (if server has a path component) + if parsed.path and parsed.path != "/": + path_based_url = urljoin(base_url, f"/.well-known/oauth-protected-resource{parsed.path}") + urls.append(path_based_url) + + # Priority 3: Root-based well-known URI + root_based_url = urljoin(base_url, "/.well-known/oauth-protected-resource") + urls.append(root_based_url) + + return urls + + +def get_client_metadata_scopes( + www_authenticate_scope: str | None, + protected_resource_metadata: ProtectedResourceMetadata | None, + authorization_server_metadata: OAuthMetadata | None = None, +) -> str | None: + """Select scopes as outlined in the 'Scope Selection Strategy' in the MCP spec.""" + # Per MCP spec, scope selection priority order: + # 1. Use scope from WWW-Authenticate header (if provided) + # 2. Use all scopes from PRM scopes_supported (if available) + # 3. Omit scope parameter if neither is available + + if www_authenticate_scope is not None: + # Priority 1: WWW-Authenticate header scope + return www_authenticate_scope + elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None: + # Priority 2: PRM scopes_supported + return " ".join(protected_resource_metadata.scopes_supported) + elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None: + return " ".join(authorization_server_metadata.scopes_supported) # pragma: no cover + else: + # Priority 3: Omit scope parameter + return None + + +def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: str | None, server_url: str) -> list[str]: + """ + Generate ordered list of (url, type) tuples for discovery attempts. + + Args: + auth_server_url: URL for the OAuth Authorization Metadata URL if found, otherwise None + server_url: URL for the MCP server, used as a fallback if auth_server_url is None + """ + + if not auth_server_url: + # Legacy path using the 2025-03-26 spec: + # link: https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization + parsed = urlparse(server_url) + return [f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-authorization-server"] + + urls: list[str] = [] + parsed = urlparse(auth_server_url) + base_url = f"{parsed.scheme}://{parsed.netloc}" + + # RFC 8414: Path-aware OAuth discovery + if parsed.path and parsed.path != "/": + oauth_path = f"/.well-known/oauth-authorization-server{parsed.path.rstrip('/')}" + urls.append(urljoin(base_url, oauth_path)) + + # RFC 8414 section 5: Path-aware OIDC discovery + # See https://www.rfc-editor.org/rfc/rfc8414.html#section-5 + oidc_path = f"/.well-known/openid-configuration{parsed.path.rstrip('/')}" + urls.append(urljoin(base_url, oidc_path)) + + # https://openid.net/specs/openid-connect-discovery-1_0.html + oidc_path = f"{parsed.path.rstrip('/')}/.well-known/openid-configuration" + urls.append(urljoin(base_url, oidc_path)) + return urls + + # OAuth root + urls.append(urljoin(base_url, "/.well-known/oauth-authorization-server")) + + # OIDC 1.0 fallback (appends to full URL per OIDC spec) + # https://openid.net/specs/openid-connect-discovery-1_0.html + urls.append(urljoin(base_url, "/.well-known/openid-configuration")) + + return urls + + +async def handle_protected_resource_response( + response: Response, +) -> ProtectedResourceMetadata | None: + """ + Handle protected resource metadata discovery response. + + Per SEP-985, supports fallback when discovery fails at one URL. + + Returns: + True if metadata was successfully discovered, False if we should try next URL + """ + if response.status_code == 200: + try: + content = await response.aread() + metadata = ProtectedResourceMetadata.model_validate_json(content) + return metadata + + except ValidationError: # pragma: no cover + # Invalid metadata - try next URL + return None + else: + # Not found - try next URL in fallback chain + return None + + +async def handle_auth_metadata_response(response: Response) -> tuple[bool, OAuthMetadata | None]: + if response.status_code == 200: + try: + content = await response.aread() + asm = OAuthMetadata.model_validate_json(content) + return True, asm + except ValidationError: # pragma: no cover + return True, None + elif response.status_code < 400 or response.status_code >= 500: + return False, None # Non-4XX error, stop trying + return True, None + + +def create_oauth_metadata_request(url: str) -> Request: + return Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION}) + + +def create_client_registration_request( + auth_server_metadata: OAuthMetadata | None, client_metadata: OAuthClientMetadata, auth_base_url: str +) -> Request: + """Build registration request or skip if already registered.""" + + if auth_server_metadata and auth_server_metadata.registration_endpoint: + registration_url = str(auth_server_metadata.registration_endpoint) + else: + registration_url = urljoin(auth_base_url, "/register") + + registration_data = client_metadata.model_dump(by_alias=True, mode="json", exclude_none=True) + + return Request("POST", registration_url, json=registration_data, headers={"Content-Type": "application/json"}) + + +async def handle_registration_response(response: Response) -> OAuthClientInformationFull: + """Handle registration response.""" + if response.status_code not in (200, 201): + await response.aread() + raise OAuthRegistrationError(f"Registration failed: {response.status_code} {response.text}") + + try: + content = await response.aread() + client_info = OAuthClientInformationFull.model_validate_json(content) + return client_info + # self.context.client_info = client_info + # await self.context.storage.set_client_info(client_info) + except ValidationError as e: # pragma: no cover + raise OAuthRegistrationError(f"Invalid registration response: {e}") + + +def is_valid_client_metadata_url(url: str | None) -> bool: + """Validate that a URL is suitable for use as a client_id (CIMD). + + The URL must be HTTPS with a non-root pathname. + + Args: + url: The URL to validate + + Returns: + True if the URL is a valid HTTPS URL with a non-root pathname + """ + if not url: + return False + try: + parsed = urlparse(url) + return parsed.scheme == "https" and parsed.path not in ("", "/") + except Exception: + return False + + +def should_use_client_metadata_url( + oauth_metadata: OAuthMetadata | None, + client_metadata_url: str | None, +) -> bool: + """Determine if URL-based client ID (CIMD) should be used instead of DCR. + + URL-based client IDs should be used when: + 1. The server advertises client_id_metadata_document_supported=true + 2. The client has a valid client_metadata_url configured + + Args: + oauth_metadata: OAuth authorization server metadata + client_metadata_url: URL-based client ID (already validated) + + Returns: + True if CIMD should be used, False if DCR should be used + """ + if not client_metadata_url: + return False + + if not oauth_metadata: + return False + + return oauth_metadata.client_id_metadata_document_supported is True + + +def create_client_info_from_metadata_url( + client_metadata_url: str, redirect_uris: list[AnyUrl] | None = None +) -> OAuthClientInformationFull: + """Create client information using a URL-based client ID (CIMD). + + When using URL-based client IDs, the URL itself becomes the client_id + and no client_secret is used (token_endpoint_auth_method="none"). + + Args: + client_metadata_url: The URL to use as the client_id + redirect_uris: The redirect URIs from the client metadata (passed through for + compatibility with OAuthClientInformationFull which inherits from OAuthClientMetadata) + + Returns: + OAuthClientInformationFull with the URL as client_id + """ + return OAuthClientInformationFull( + client_id=client_metadata_url, + token_endpoint_auth_method="none", + redirect_uris=redirect_uris, + ) + + +async def handle_token_response_scopes( + response: Response, +) -> OAuthToken: + """Parse and validate token response with optional scope validation. + + Parses token response JSON. Callers should check response.status_code before calling. + + Args: + response: HTTP response from token endpoint (status already checked by caller) + + Returns: + Validated OAuthToken model + + Raises: + OAuthTokenError: If response JSON is invalid + """ + try: + content = await response.aread() + token_response = OAuthToken.model_validate_json(content) + return token_response + except ValidationError as e: # pragma: no cover + raise OAuthTokenError(f"Invalid token response: {e}") diff --git a/src/mcp/client/experimental/__init__.py b/src/mcp/client/experimental/__init__.py new file mode 100644 index 0000000000..b6579b191e --- /dev/null +++ b/src/mcp/client/experimental/__init__.py @@ -0,0 +1,9 @@ +""" +Experimental client features. + +WARNING: These APIs are experimental and may change without notice. +""" + +from mcp.client.experimental.tasks import ExperimentalClientFeatures + +__all__ = ["ExperimentalClientFeatures"] diff --git a/src/mcp/client/experimental/task_handlers.py b/src/mcp/client/experimental/task_handlers.py new file mode 100644 index 0000000000..a47508674b --- /dev/null +++ b/src/mcp/client/experimental/task_handlers.py @@ -0,0 +1,290 @@ +""" +Experimental task handler protocols for server -> client requests. + +This module provides Protocol types and default handlers for when servers +send task-related requests to clients (the reverse of normal client -> server flow). + +WARNING: These APIs are experimental and may change without notice. + +Use cases: +- Server sends task-augmented sampling/elicitation request to client +- Client creates a local task, spawns background work, returns CreateTaskResult +- Server polls client's task status via tasks/get, tasks/result, etc. +""" + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Protocol + +from pydantic import TypeAdapter + +import mcp.types as types +from mcp.shared.context import RequestContext +from mcp.shared.session import RequestResponder + +if TYPE_CHECKING: + from mcp.client.session import ClientSession + + +class GetTaskHandlerFnT(Protocol): + """Handler for tasks/get requests from server. + + WARNING: This is experimental and may change without notice. + """ + + async def __call__( + self, + context: RequestContext["ClientSession", Any], + params: types.GetTaskRequestParams, + ) -> types.GetTaskResult | types.ErrorData: ... # pragma: no branch + + +class GetTaskResultHandlerFnT(Protocol): + """Handler for tasks/result requests from server. + + WARNING: This is experimental and may change without notice. + """ + + async def __call__( + self, + context: RequestContext["ClientSession", Any], + params: types.GetTaskPayloadRequestParams, + ) -> types.GetTaskPayloadResult | types.ErrorData: ... # pragma: no branch + + +class ListTasksHandlerFnT(Protocol): + """Handler for tasks/list requests from server. + + WARNING: This is experimental and may change without notice. + """ + + async def __call__( + self, + context: RequestContext["ClientSession", Any], + params: types.PaginatedRequestParams | None, + ) -> types.ListTasksResult | types.ErrorData: ... # pragma: no branch + + +class CancelTaskHandlerFnT(Protocol): + """Handler for tasks/cancel requests from server. + + WARNING: This is experimental and may change without notice. + """ + + async def __call__( + self, + context: RequestContext["ClientSession", Any], + params: types.CancelTaskRequestParams, + ) -> types.CancelTaskResult | types.ErrorData: ... # pragma: no branch + + +class TaskAugmentedSamplingFnT(Protocol): + """Handler for task-augmented sampling/createMessage requests from server. + + When server sends a CreateMessageRequest with task field, this callback + is invoked. The callback should create a task, spawn background work, + and return CreateTaskResult immediately. + + WARNING: This is experimental and may change without notice. + """ + + async def __call__( + self, + context: RequestContext["ClientSession", Any], + params: types.CreateMessageRequestParams, + task_metadata: types.TaskMetadata, + ) -> types.CreateTaskResult | types.ErrorData: ... # pragma: no branch + + +class TaskAugmentedElicitationFnT(Protocol): + """Handler for task-augmented elicitation/create requests from server. + + When server sends an ElicitRequest with task field, this callback + is invoked. The callback should create a task, spawn background work, + and return CreateTaskResult immediately. + + WARNING: This is experimental and may change without notice. + """ + + async def __call__( + self, + context: RequestContext["ClientSession", Any], + params: types.ElicitRequestParams, + task_metadata: types.TaskMetadata, + ) -> types.CreateTaskResult | types.ErrorData: ... # pragma: no branch + + +async def default_get_task_handler( + context: RequestContext["ClientSession", Any], + params: types.GetTaskRequestParams, +) -> types.GetTaskResult | types.ErrorData: + return types.ErrorData( + code=types.METHOD_NOT_FOUND, + message="tasks/get not supported", + ) + + +async def default_get_task_result_handler( + context: RequestContext["ClientSession", Any], + params: types.GetTaskPayloadRequestParams, +) -> types.GetTaskPayloadResult | types.ErrorData: + return types.ErrorData( + code=types.METHOD_NOT_FOUND, + message="tasks/result not supported", + ) + + +async def default_list_tasks_handler( + context: RequestContext["ClientSession", Any], + params: types.PaginatedRequestParams | None, +) -> types.ListTasksResult | types.ErrorData: + return types.ErrorData( + code=types.METHOD_NOT_FOUND, + message="tasks/list not supported", + ) + + +async def default_cancel_task_handler( + context: RequestContext["ClientSession", Any], + params: types.CancelTaskRequestParams, +) -> types.CancelTaskResult | types.ErrorData: + return types.ErrorData( + code=types.METHOD_NOT_FOUND, + message="tasks/cancel not supported", + ) + + +async def default_task_augmented_sampling( + context: RequestContext["ClientSession", Any], + params: types.CreateMessageRequestParams, + task_metadata: types.TaskMetadata, +) -> types.CreateTaskResult | types.ErrorData: + return types.ErrorData( + code=types.INVALID_REQUEST, + message="Task-augmented sampling not supported", + ) + + +async def default_task_augmented_elicitation( + context: RequestContext["ClientSession", Any], + params: types.ElicitRequestParams, + task_metadata: types.TaskMetadata, +) -> types.CreateTaskResult | types.ErrorData: + return types.ErrorData( + code=types.INVALID_REQUEST, + message="Task-augmented elicitation not supported", + ) + + +@dataclass +class ExperimentalTaskHandlers: + """Container for experimental task handlers. + + Groups all task-related handlers that handle server -> client requests. + This includes both pure task requests (get, list, cancel, result) and + task-augmented request handlers (sampling, elicitation with task field). + + WARNING: These APIs are experimental and may change without notice. + + Example: + handlers = ExperimentalTaskHandlers( + get_task=my_get_task_handler, + list_tasks=my_list_tasks_handler, + ) + session = ClientSession(..., experimental_task_handlers=handlers) + """ + + # Pure task request handlers + get_task: GetTaskHandlerFnT = field(default=default_get_task_handler) + get_task_result: GetTaskResultHandlerFnT = field(default=default_get_task_result_handler) + list_tasks: ListTasksHandlerFnT = field(default=default_list_tasks_handler) + cancel_task: CancelTaskHandlerFnT = field(default=default_cancel_task_handler) + + # Task-augmented request handlers + augmented_sampling: TaskAugmentedSamplingFnT = field(default=default_task_augmented_sampling) + augmented_elicitation: TaskAugmentedElicitationFnT = field(default=default_task_augmented_elicitation) + + def build_capability(self) -> types.ClientTasksCapability | None: + """Build ClientTasksCapability from the configured handlers. + + Returns a capability object that reflects which handlers are configured + (i.e., not using the default "not supported" handlers). + + Returns: + ClientTasksCapability if any handlers are provided, None otherwise + """ + has_list = self.list_tasks is not default_list_tasks_handler + has_cancel = self.cancel_task is not default_cancel_task_handler + has_sampling = self.augmented_sampling is not default_task_augmented_sampling + has_elicitation = self.augmented_elicitation is not default_task_augmented_elicitation + + # If no handlers are provided, return None + if not any([has_list, has_cancel, has_sampling, has_elicitation]): + return None + + # Build requests capability if any request handlers are provided + requests_capability: types.ClientTasksRequestsCapability | None = None + if has_sampling or has_elicitation: + requests_capability = types.ClientTasksRequestsCapability( + sampling=types.TasksSamplingCapability(createMessage=types.TasksCreateMessageCapability()) + if has_sampling + else None, + elicitation=types.TasksElicitationCapability(create=types.TasksCreateElicitationCapability()) + if has_elicitation + else None, + ) + + return types.ClientTasksCapability( + list=types.TasksListCapability() if has_list else None, + cancel=types.TasksCancelCapability() if has_cancel else None, + requests=requests_capability, + ) + + @staticmethod + def handles_request(request: types.ServerRequest) -> bool: + """Check if this handler handles the given request type.""" + return isinstance( + request.root, + types.GetTaskRequest | types.GetTaskPayloadRequest | types.ListTasksRequest | types.CancelTaskRequest, + ) + + async def handle_request( + self, + ctx: RequestContext["ClientSession", Any], + responder: RequestResponder[types.ServerRequest, types.ClientResult], + ) -> None: + """Handle a task-related request from the server. + + Call handles_request() first to check if this handler can handle the request. + """ + client_response_type: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter( + types.ClientResult | types.ErrorData + ) + + match responder.request.root: + case types.GetTaskRequest(params=params): + response = await self.get_task(ctx, params) + client_response = client_response_type.validate_python(response) + await responder.respond(client_response) + + case types.GetTaskPayloadRequest(params=params): + response = await self.get_task_result(ctx, params) + client_response = client_response_type.validate_python(response) + await responder.respond(client_response) + + case types.ListTasksRequest(params=params): + response = await self.list_tasks(ctx, params) + client_response = client_response_type.validate_python(response) + await responder.respond(client_response) + + case types.CancelTaskRequest(params=params): + response = await self.cancel_task(ctx, params) + client_response = client_response_type.validate_python(response) + await responder.respond(client_response) + + case _: # pragma: no cover + raise ValueError(f"Unhandled request type: {type(responder.request.root)}") + + +# Backwards compatibility aliases +default_task_augmented_sampling_callback = default_task_augmented_sampling +default_task_augmented_elicitation_callback = default_task_augmented_elicitation diff --git a/src/mcp/client/experimental/tasks.py b/src/mcp/client/experimental/tasks.py new file mode 100644 index 0000000000..ce9c387462 --- /dev/null +++ b/src/mcp/client/experimental/tasks.py @@ -0,0 +1,224 @@ +""" +Experimental client-side task support. + +This module provides client methods for interacting with MCP tasks. + +WARNING: These APIs are experimental and may change without notice. + +Example: + # Call a tool as a task + result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) + task_id = result.task.taskId + + # Get task status + status = await session.experimental.get_task(task_id) + + # Get task result when complete + if status.status == "completed": + result = await session.experimental.get_task_result(task_id, CallToolResult) + + # List all tasks + tasks = await session.experimental.list_tasks() + + # Cancel a task + await session.experimental.cancel_task(task_id) +""" + +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any, TypeVar + +import mcp.types as types +from mcp.shared.experimental.tasks.polling import poll_until_terminal + +if TYPE_CHECKING: + from mcp.client.session import ClientSession + +ResultT = TypeVar("ResultT", bound=types.Result) + + +class ExperimentalClientFeatures: + """ + Experimental client features for tasks and other experimental APIs. + + WARNING: These APIs are experimental and may change without notice. + + Access via session.experimental: + status = await session.experimental.get_task(task_id) + """ + + def __init__(self, session: "ClientSession") -> None: + self._session = session + + async def call_tool_as_task( + self, + name: str, + arguments: dict[str, Any] | None = None, + *, + ttl: int = 60000, + meta: dict[str, Any] | None = None, + ) -> types.CreateTaskResult: + """Call a tool as a task, returning a CreateTaskResult for polling. + + This is a convenience method for calling tools that support task execution. + The server will return a task reference instead of the immediate result, + which can then be polled via `get_task()` and retrieved via `get_task_result()`. + + Args: + name: The tool name + arguments: Tool arguments + ttl: Task time-to-live in milliseconds (default: 60000 = 1 minute) + meta: Optional metadata to include in the request + + Returns: + CreateTaskResult containing the task reference + + Example: + # Create task + result = await session.experimental.call_tool_as_task( + "long_running_tool", {"input": "data"} + ) + task_id = result.task.taskId + + # Poll for completion + while True: + status = await session.experimental.get_task(task_id) + if status.status == "completed": + break + await asyncio.sleep(0.5) + + # Get result + final = await session.experimental.get_task_result(task_id, CallToolResult) + """ + _meta: types.RequestParams.Meta | None = None + if meta is not None: + _meta = types.RequestParams.Meta(**meta) + + return await self._session.send_request( + types.ClientRequest( + types.CallToolRequest( + params=types.CallToolRequestParams( + name=name, + arguments=arguments, + task=types.TaskMetadata(ttl=ttl), + _meta=_meta, + ), + ) + ), + types.CreateTaskResult, + ) + + async def get_task(self, task_id: str) -> types.GetTaskResult: + """ + Get the current status of a task. + + Args: + task_id: The task identifier + + Returns: + GetTaskResult containing the task status and metadata + """ + return await self._session.send_request( + types.ClientRequest( + types.GetTaskRequest( + params=types.GetTaskRequestParams(taskId=task_id), + ) + ), + types.GetTaskResult, + ) + + async def get_task_result( + self, + task_id: str, + result_type: type[ResultT], + ) -> ResultT: + """ + Get the result of a completed task. + + The result type depends on the original request type: + - tools/call tasks return CallToolResult + - Other request types return their corresponding result type + + Args: + task_id: The task identifier + result_type: The expected result type (e.g., CallToolResult) + + Returns: + The task result, validated against result_type + """ + return await self._session.send_request( + types.ClientRequest( + types.GetTaskPayloadRequest( + params=types.GetTaskPayloadRequestParams(taskId=task_id), + ) + ), + result_type, + ) + + async def list_tasks( + self, + cursor: str | None = None, + ) -> types.ListTasksResult: + """ + List all tasks. + + Args: + cursor: Optional pagination cursor + + Returns: + ListTasksResult containing tasks and optional next cursor + """ + params = types.PaginatedRequestParams(cursor=cursor) if cursor else None + return await self._session.send_request( + types.ClientRequest( + types.ListTasksRequest(params=params), + ), + types.ListTasksResult, + ) + + async def cancel_task(self, task_id: str) -> types.CancelTaskResult: + """ + Cancel a running task. + + Args: + task_id: The task identifier + + Returns: + CancelTaskResult with the updated task state + """ + return await self._session.send_request( + types.ClientRequest( + types.CancelTaskRequest( + params=types.CancelTaskRequestParams(taskId=task_id), + ) + ), + types.CancelTaskResult, + ) + + async def poll_task(self, task_id: str) -> AsyncIterator[types.GetTaskResult]: + """ + Poll a task until it reaches a terminal status. + + Yields GetTaskResult for each poll, allowing the caller to react to + status changes (e.g., handle input_required). Exits when task reaches + a terminal status (completed, failed, cancelled). + + Respects the pollInterval hint from the server. + + Args: + task_id: The task identifier + + Yields: + GetTaskResult for each poll + + Example: + async for status in session.experimental.poll_task(task_id): + print(f"Status: {status.status}") + if status.status == "input_required": + # Handle elicitation request via tasks/result + pass + + # Task is now terminal, get the result + result = await session.experimental.get_task_result(task_id, CallToolResult) + """ + async for status in poll_until_terminal(self.get_task, task_id): + yield status diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index e29797d177..a7d03f87cd 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -1,52 +1,62 @@ -from datetime import timedelta -from typing import Any, Protocol +import logging +from typing import Any, Protocol, overload import anyio.lowlevel from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import AnyUrl, TypeAdapter +from typing_extensions import deprecated import mcp.types as types +from mcp.client.experimental import ExperimentalClientFeatures +from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers from mcp.shared.context import RequestContext -from mcp.shared.session import BaseSession, RequestResponder +from mcp.shared.message import SessionMessage +from mcp.shared.session import BaseSession, ProgressFnT, RequestResponder from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") +logger = logging.getLogger("client") + class SamplingFnT(Protocol): async def __call__( self, context: RequestContext["ClientSession", Any], params: types.CreateMessageRequestParams, - ) -> types.CreateMessageResult | types.ErrorData: ... + ) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData: ... # pragma: no branch + + +class ElicitationFnT(Protocol): + async def __call__( + self, + context: RequestContext["ClientSession", Any], + params: types.ElicitRequestParams, + ) -> types.ElicitResult | types.ErrorData: ... # pragma: no branch class ListRootsFnT(Protocol): async def __call__( self, context: RequestContext["ClientSession", Any] - ) -> types.ListRootsResult | types.ErrorData: ... + ) -> types.ListRootsResult | types.ErrorData: ... # pragma: no branch class LoggingFnT(Protocol): async def __call__( self, params: types.LoggingMessageNotificationParams, - ) -> None: ... + ) -> None: ... # pragma: no branch class MessageHandlerFnT(Protocol): async def __call__( self, - message: RequestResponder[types.ServerRequest, types.ClientResult] - | types.ServerNotification - | Exception, - ) -> None: ... + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: ... # pragma: no branch async def _default_message_handler( - message: RequestResponder[types.ServerRequest, types.ClientResult] - | types.ServerNotification - | Exception, + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, ) -> None: await anyio.lowlevel.checkpoint() @@ -54,13 +64,23 @@ async def _default_message_handler( async def _default_sampling_callback( context: RequestContext["ClientSession", Any], params: types.CreateMessageRequestParams, -) -> types.CreateMessageResult | types.ErrorData: +) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData: return types.ErrorData( code=types.INVALID_REQUEST, message="Sampling not supported", ) +async def _default_elicitation_callback( + context: RequestContext["ClientSession", Any], + params: types.ElicitRequestParams, +) -> types.ElicitResult | types.ErrorData: + return types.ErrorData( # pragma: no cover + code=types.INVALID_REQUEST, + message="Elicitation not supported", + ) + + async def _default_list_roots_callback( context: RequestContext["ClientSession", Any], ) -> types.ListRootsResult | types.ErrorData: @@ -76,9 +96,7 @@ async def _default_logging_callback( pass -ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter( - types.ClientResult | types.ErrorData -) +ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData) class ClientSession( @@ -92,14 +110,18 @@ class ClientSession( ): def __init__( self, - read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception], - write_stream: MemoryObjectSendStream[types.JSONRPCMessage], - read_timeout_seconds: timedelta | None = None, + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], + write_stream: MemoryObjectSendStream[SessionMessage], + read_timeout_seconds: float | None = None, sampling_callback: SamplingFnT | None = None, + elicitation_callback: ElicitationFnT | None = None, list_roots_callback: ListRootsFnT | None = None, logging_callback: LoggingFnT | None = None, message_handler: MessageHandlerFnT | None = None, client_info: types.Implementation | None = None, + *, + sampling_capabilities: types.SamplingCapability | None = None, + experimental_task_handlers: ExperimentalTaskHandlers | None = None, ) -> None: super().__init__( read_stream, @@ -110,29 +132,52 @@ def __init__( ) self._client_info = client_info or DEFAULT_CLIENT_INFO self._sampling_callback = sampling_callback or _default_sampling_callback + self._sampling_capabilities = sampling_capabilities + self._elicitation_callback = elicitation_callback or _default_elicitation_callback self._list_roots_callback = list_roots_callback or _default_list_roots_callback self._logging_callback = logging_callback or _default_logging_callback self._message_handler = message_handler or _default_message_handler + self._tool_output_schemas: dict[str, dict[str, Any] | None] = {} + self._server_capabilities: types.ServerCapabilities | None = None + self._experimental_features: ExperimentalClientFeatures | None = None + + # Experimental: Task handlers (use defaults if not provided) + self._task_handlers = experimental_task_handlers or ExperimentalTaskHandlers() async def initialize(self) -> types.InitializeResult: - sampling = types.SamplingCapability() - roots = types.RootsCapability( + sampling = ( + (self._sampling_capabilities or types.SamplingCapability()) + if self._sampling_callback is not _default_sampling_callback + else None + ) + elicitation = ( + types.ElicitationCapability( + form=types.FormElicitationCapability(), + url=types.UrlElicitationCapability(), + ) + if self._elicitation_callback is not _default_elicitation_callback + else None + ) + roots = ( # TODO: Should this be based on whether we # _will_ send notifications, or only whether # they're supported? - listChanged=True, + types.RootsCapability(listChanged=True) + if self._list_roots_callback is not _default_list_roots_callback + else None ) result = await self.send_request( types.ClientRequest( types.InitializeRequest( - method="initialize", params=types.InitializeRequestParams( protocolVersion=types.LATEST_PROTOCOL_VERSION, capabilities=types.ClientCapabilities( sampling=sampling, + elicitation=elicitation, experimental=None, roots=roots, + tasks=self._task_handlers.build_capability(), ), clientInfo=self._client_info, ), @@ -142,42 +187,58 @@ async def initialize(self) -> types.InitializeResult: ) if result.protocolVersion not in SUPPORTED_PROTOCOL_VERSIONS: - raise RuntimeError( - "Unsupported protocol version from the server: " - f"{result.protocolVersion}" - ) + raise RuntimeError(f"Unsupported protocol version from the server: {result.protocolVersion}") - await self.send_notification( - types.ClientNotification( - types.InitializedNotification(method="notifications/initialized") - ) - ) + self._server_capabilities = result.capabilities + + await self.send_notification(types.ClientNotification(types.InitializedNotification())) return result + def get_server_capabilities(self) -> types.ServerCapabilities | None: + """Return the server capabilities received during initialization. + + Returns None if the session has not been initialized yet. + """ + return self._server_capabilities + + @property + def experimental(self) -> ExperimentalClientFeatures: + """Experimental APIs for tasks and other features. + + WARNING: These APIs are experimental and may change without notice. + + Example: + status = await session.experimental.get_task(task_id) + result = await session.experimental.get_task_result(task_id, CallToolResult) + """ + if self._experimental_features is None: + self._experimental_features = ExperimentalClientFeatures(self) + return self._experimental_features + async def send_ping(self) -> types.EmptyResult: """Send a ping request.""" return await self.send_request( - types.ClientRequest( - types.PingRequest( - method="ping", - ) - ), + types.ClientRequest(types.PingRequest()), types.EmptyResult, ) async def send_progress_notification( - self, progress_token: str | int, progress: float, total: float | None = None + self, + progress_token: str | int, + progress: float, + total: float | None = None, + message: str | None = None, ) -> None: """Send a progress notification.""" await self.send_notification( types.ClientNotification( types.ProgressNotification( - method="notifications/progress", params=types.ProgressNotificationParams( progressToken=progress_token, progress=progress, total=total, + message=message, ), ), ) @@ -185,35 +246,88 @@ async def send_progress_notification( async def set_logging_level(self, level: types.LoggingLevel) -> types.EmptyResult: """Send a logging/setLevel request.""" - return await self.send_request( + return await self.send_request( # pragma: no cover types.ClientRequest( types.SetLevelRequest( - method="logging/setLevel", params=types.SetLevelRequestParams(level=level), ) ), types.EmptyResult, ) - async def list_resources(self) -> types.ListResourcesResult: - """Send a resources/list request.""" + @overload + @deprecated("Use list_resources(params=PaginatedRequestParams(...)) instead") + async def list_resources(self, cursor: str | None) -> types.ListResourcesResult: ... + + @overload + async def list_resources(self, *, params: types.PaginatedRequestParams | None) -> types.ListResourcesResult: ... + + @overload + async def list_resources(self) -> types.ListResourcesResult: ... + + async def list_resources( + self, + cursor: str | None = None, + *, + params: types.PaginatedRequestParams | None = None, + ) -> types.ListResourcesResult: + """Send a resources/list request. + + Args: + cursor: Simple cursor string for pagination (deprecated, use params instead) + params: Full pagination parameters including cursor and any future fields + """ + if params is not None and cursor is not None: + raise ValueError("Cannot specify both cursor and params") + + if params is not None: + request_params = params + elif cursor is not None: + request_params = types.PaginatedRequestParams(cursor=cursor) + else: + request_params = None + return await self.send_request( - types.ClientRequest( - types.ListResourcesRequest( - method="resources/list", - ) - ), + types.ClientRequest(types.ListResourcesRequest(params=request_params)), types.ListResourcesResult, ) - async def list_resource_templates(self) -> types.ListResourceTemplatesResult: - """Send a resources/templates/list request.""" + @overload + @deprecated("Use list_resource_templates(params=PaginatedRequestParams(...)) instead") + async def list_resource_templates(self, cursor: str | None) -> types.ListResourceTemplatesResult: ... + + @overload + async def list_resource_templates( + self, *, params: types.PaginatedRequestParams | None + ) -> types.ListResourceTemplatesResult: ... + + @overload + async def list_resource_templates(self) -> types.ListResourceTemplatesResult: ... + + async def list_resource_templates( + self, + cursor: str | None = None, + *, + params: types.PaginatedRequestParams | None = None, + ) -> types.ListResourceTemplatesResult: + """Send a resources/templates/list request. + + Args: + cursor: Simple cursor string for pagination (deprecated, use params instead) + params: Full pagination parameters including cursor and any future fields + """ + if params is not None and cursor is not None: + raise ValueError("Cannot specify both cursor and params") + + if params is not None: + request_params = params + elif cursor is not None: + request_params = types.PaginatedRequestParams(cursor=cursor) + else: + request_params = None + return await self.send_request( - types.ClientRequest( - types.ListResourceTemplatesRequest( - method="resources/templates/list", - ) - ), + types.ClientRequest(types.ListResourceTemplatesRequest(params=request_params)), types.ListResourceTemplatesResult, ) @@ -222,7 +336,6 @@ async def read_resource(self, uri: AnyUrl) -> types.ReadResourceResult: return await self.send_request( types.ClientRequest( types.ReadResourceRequest( - method="resources/read", params=types.ReadResourceRequestParams(uri=uri), ) ), @@ -231,10 +344,9 @@ async def read_resource(self, uri: AnyUrl) -> types.ReadResourceResult: async def subscribe_resource(self, uri: AnyUrl) -> types.EmptyResult: """Send a resources/subscribe request.""" - return await self.send_request( + return await self.send_request( # pragma: no cover types.ClientRequest( types.SubscribeRequest( - method="resources/subscribe", params=types.SubscribeRequestParams(uri=uri), ) ), @@ -243,10 +355,9 @@ async def subscribe_resource(self, uri: AnyUrl) -> types.EmptyResult: async def unsubscribe_resource(self, uri: AnyUrl) -> types.EmptyResult: """Send a resources/unsubscribe request.""" - return await self.send_request( + return await self.send_request( # pragma: no cover types.ClientRequest( types.UnsubscribeRequest( - method="resources/unsubscribe", params=types.UnsubscribeRequestParams(uri=uri), ) ), @@ -254,38 +365,104 @@ async def unsubscribe_resource(self, uri: AnyUrl) -> types.EmptyResult: ) async def call_tool( - self, name: str, arguments: dict[str, Any] | None = None + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + meta: dict[str, Any] | None = None, ) -> types.CallToolResult: - """Send a tools/call request.""" - return await self.send_request( + """Send a tools/call request with optional progress callback support.""" + + _meta: types.RequestParams.Meta | None = None + if meta is not None: + _meta = types.RequestParams.Meta(**meta) + + result = await self.send_request( types.ClientRequest( types.CallToolRequest( - method="tools/call", - params=types.CallToolRequestParams(name=name, arguments=arguments), + params=types.CallToolRequestParams(name=name, arguments=arguments, _meta=_meta), ) ), types.CallToolResult, + request_read_timeout_seconds=read_timeout_seconds, + progress_callback=progress_callback, ) - async def list_prompts(self) -> types.ListPromptsResult: - """Send a prompts/list request.""" + if not result.isError: + await self._validate_tool_result(name, result) + + return result + + async def _validate_tool_result(self, name: str, result: types.CallToolResult) -> None: + """Validate the structured content of a tool result against its output schema.""" + if name not in self._tool_output_schemas: + # refresh output schema cache + await self.list_tools() + + output_schema = None + if name in self._tool_output_schemas: + output_schema = self._tool_output_schemas.get(name) + else: + logger.warning(f"Tool {name} not listed by server, cannot validate any structured content") + + if output_schema is not None: + from jsonschema import SchemaError, ValidationError, validate + + if result.structuredContent is None: + raise RuntimeError( + f"Tool {name} has an output schema but did not return structured content" + ) # pragma: no cover + try: + validate(result.structuredContent, output_schema) + except ValidationError as e: + raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}") # pragma: no cover + except SchemaError as e: # pragma: no cover + raise RuntimeError(f"Invalid schema for tool {name}: {e}") # pragma: no cover + + @overload + @deprecated("Use list_prompts(params=PaginatedRequestParams(...)) instead") + async def list_prompts(self, cursor: str | None) -> types.ListPromptsResult: ... + + @overload + async def list_prompts(self, *, params: types.PaginatedRequestParams | None) -> types.ListPromptsResult: ... + + @overload + async def list_prompts(self) -> types.ListPromptsResult: ... + + async def list_prompts( + self, + cursor: str | None = None, + *, + params: types.PaginatedRequestParams | None = None, + ) -> types.ListPromptsResult: + """Send a prompts/list request. + + Args: + cursor: Simple cursor string for pagination (deprecated, use params instead) + params: Full pagination parameters including cursor and any future fields + """ + if params is not None and cursor is not None: + raise ValueError("Cannot specify both cursor and params") + + if params is not None: + request_params = params + elif cursor is not None: + request_params = types.PaginatedRequestParams(cursor=cursor) + else: + request_params = None + return await self.send_request( - types.ClientRequest( - types.ListPromptsRequest( - method="prompts/list", - ) - ), + types.ClientRequest(types.ListPromptsRequest(params=request_params)), types.ListPromptsResult, ) - async def get_prompt( - self, name: str, arguments: dict[str, str] | None = None - ) -> types.GetPromptResult: + async def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult: """Send a prompts/get request.""" return await self.send_request( types.ClientRequest( types.GetPromptRequest( - method="prompts/get", params=types.GetPromptRequestParams(name=name, arguments=arguments), ) ), @@ -294,47 +471,77 @@ async def get_prompt( async def complete( self, - ref: types.ResourceReference | types.PromptReference, + ref: types.ResourceTemplateReference | types.PromptReference, argument: dict[str, str], + context_arguments: dict[str, str] | None = None, ) -> types.CompleteResult: """Send a completion/complete request.""" + context = None + if context_arguments is not None: + context = types.CompletionContext(arguments=context_arguments) + return await self.send_request( types.ClientRequest( types.CompleteRequest( - method="completion/complete", params=types.CompleteRequestParams( ref=ref, argument=types.CompletionArgument(**argument), + context=context, ), ) ), types.CompleteResult, ) - async def list_tools(self) -> types.ListToolsResult: - """Send a tools/list request.""" - return await self.send_request( - types.ClientRequest( - types.ListToolsRequest( - method="tools/list", - ) - ), + @overload + @deprecated("Use list_tools(params=PaginatedRequestParams(...)) instead") + async def list_tools(self, cursor: str | None) -> types.ListToolsResult: ... + + @overload + async def list_tools(self, *, params: types.PaginatedRequestParams | None) -> types.ListToolsResult: ... + + @overload + async def list_tools(self) -> types.ListToolsResult: ... + + async def list_tools( + self, + cursor: str | None = None, + *, + params: types.PaginatedRequestParams | None = None, + ) -> types.ListToolsResult: + """Send a tools/list request. + + Args: + cursor: Simple cursor string for pagination (deprecated, use params instead) + params: Full pagination parameters including cursor and any future fields + """ + if params is not None and cursor is not None: + raise ValueError("Cannot specify both cursor and params") + + if params is not None: + request_params = params + elif cursor is not None: + request_params = types.PaginatedRequestParams(cursor=cursor) + else: + request_params = None + + result = await self.send_request( + types.ClientRequest(types.ListToolsRequest(params=request_params)), types.ListToolsResult, ) - async def send_roots_list_changed(self) -> None: + # Cache tool output schemas for future validation + # Note: don't clear the cache, as we may be using a cursor + for tool in result.tools: + self._tool_output_schemas[tool.name] = tool.outputSchema + + return result + + async def send_roots_list_changed(self) -> None: # pragma: no cover """Send a roots/list_changed notification.""" - await self.send_notification( - types.ClientNotification( - types.RootsListChangedNotification( - method="notifications/roots/list_changed", - ) - ) - ) + await self.send_notification(types.ClientNotification(types.RootsListChangedNotification())) - async def _received_request( - self, responder: RequestResponder[types.ServerRequest, types.ClientResult] - ) -> None: + async def _received_request(self, responder: RequestResponder[types.ServerRequest, types.ClientResult]) -> None: ctx = RequestContext[ClientSession, Any]( request_id=responder.request_id, meta=responder.request_meta, @@ -342,10 +549,31 @@ async def _received_request( lifespan_context=None, ) + # Delegate to experimental task handler if applicable + if self._task_handlers.handles_request(responder.request): + with responder: + await self._task_handlers.handle_request(ctx, responder) + return None + + # Core request handling match responder.request.root: case types.CreateMessageRequest(params=params): with responder: - response = await self._sampling_callback(ctx, params) + # Check if this is a task-augmented request + if params.task is not None: + response = await self._task_handlers.augmented_sampling(ctx, params, params.task) + else: + response = await self._sampling_callback(ctx, params) + client_response = ClientResponse.validate_python(response) + await responder.respond(client_response) + + case types.ElicitRequest(params=params): + with responder: + # Check if this is a task-augmented request + if params.task is not None: + response = await self._task_handlers.augmented_elicitation(ctx, params, params.task) + else: + response = await self._elicitation_callback(ctx, params) client_response = ClientResponse.validate_python(response) await responder.respond(client_response) @@ -355,28 +583,32 @@ async def _received_request( client_response = ClientResponse.validate_python(response) await responder.respond(client_response) - case types.PingRequest(): + case types.PingRequest(): # pragma: no cover with responder: - return await responder.respond( - types.ClientResult(root=types.EmptyResult()) - ) + return await responder.respond(types.ClientResult(root=types.EmptyResult())) + + case _: # pragma: no cover + pass # Task requests handled above by _task_handlers + + return None async def _handle_incoming( self, - req: RequestResponder[types.ServerRequest, types.ClientResult] - | types.ServerNotification - | Exception, + req: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, ) -> None: """Handle incoming messages by forwarding to the message handler.""" await self._message_handler(req) - async def _received_notification( - self, notification: types.ServerNotification - ) -> None: + async def _received_notification(self, notification: types.ServerNotification) -> None: """Handle notifications from the server.""" # Process specific notification types match notification.root: case types.LoggingMessageNotification(params=params): await self._logging_callback(params) + case types.ElicitCompleteNotification(params=params): + # Handle elicitation completion notification + # Clients MAY use this to retry requests or update UI + # The notification contains the elicitationId of the completed elicitation + pass case _: pass diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py new file mode 100644 index 0000000000..db0146068a --- /dev/null +++ b/src/mcp/client/session_group.py @@ -0,0 +1,446 @@ +""" +SessionGroup concurrently manages multiple MCP session connections. + +Tools, resources, and prompts are aggregated across servers. Servers may +be connected to or disconnected from at any point after initialization. + +This abstractions can handle naming collisions using a custom user-provided +hook. +""" + +import contextlib +import logging +from collections.abc import Callable +from dataclasses import dataclass +from types import TracebackType +from typing import Any, TypeAlias, overload + +import anyio +import httpx +from pydantic import BaseModel +from typing_extensions import Self, deprecated + +import mcp +from mcp import types +from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT +from mcp.client.sse import sse_client +from mcp.client.stdio import StdioServerParameters +from mcp.client.streamable_http import streamable_http_client +from mcp.shared._httpx_utils import create_mcp_http_client +from mcp.shared.exceptions import McpError +from mcp.shared.session import ProgressFnT + + +class SseServerParameters(BaseModel): + """Parameters for intializing a sse_client.""" + + # The endpoint URL. + url: str + + # Optional headers to include in requests. + headers: dict[str, Any] | None = None + + # HTTP timeout for regular operations (in seconds). + timeout: float = 5.0 + + # Timeout for SSE read operations (in seconds). + sse_read_timeout: float = 300.0 + + +class StreamableHttpParameters(BaseModel): + """Parameters for intializing a streamable_http_client.""" + + # The endpoint URL. + url: str + + # Optional headers to include in requests. + headers: dict[str, Any] | None = None + + # HTTP timeout for regular operations (in seconds). + timeout: float = 30.0 + + # Timeout for SSE read operations (in seconds). + sse_read_timeout: float = 300.0 + + # Close the client session when the transport closes. + terminate_on_close: bool = True + + +ServerParameters: TypeAlias = StdioServerParameters | SseServerParameters | StreamableHttpParameters + + +# Use dataclass instead of pydantic BaseModel +# because pydantic BaseModel cannot handle Protocol fields. +@dataclass +class ClientSessionParameters: + """Parameters for establishing a client session to an MCP server.""" + + read_timeout_seconds: float | None = None + sampling_callback: SamplingFnT | None = None + elicitation_callback: ElicitationFnT | None = None + list_roots_callback: ListRootsFnT | None = None + logging_callback: LoggingFnT | None = None + message_handler: MessageHandlerFnT | None = None + client_info: types.Implementation | None = None + + +class ClientSessionGroup: + """Client for managing connections to multiple MCP servers. + + This class is responsible for encapsulating management of server connections. + It aggregates tools, resources, and prompts from all connected servers. + + For auxiliary handlers, such as resource subscription, this is delegated to + the client and can be accessed via the session. + + Example Usage: + name_fn = lambda name, server_info: f"{(server_info.name)}_{name}" + async with ClientSessionGroup(component_name_hook=name_fn) as group: + for server_param in server_params: + await group.connect_to_server(server_param) + ... + + """ + + class _ComponentNames(BaseModel): + """Used for reverse index to find components.""" + + prompts: set[str] = set() + resources: set[str] = set() + tools: set[str] = set() + + # Standard MCP components. + _prompts: dict[str, types.Prompt] + _resources: dict[str, types.Resource] + _tools: dict[str, types.Tool] + + # Client-server connection management. + _sessions: dict[mcp.ClientSession, _ComponentNames] + _tool_to_session: dict[str, mcp.ClientSession] + _exit_stack: contextlib.AsyncExitStack + _session_exit_stacks: dict[mcp.ClientSession, contextlib.AsyncExitStack] + + # Optional fn consuming (component_name, serverInfo) for custom names. + # This is provide a means to mitigate naming conflicts across servers. + # Example: (tool_name, serverInfo) => "{result.serverInfo.name}.{tool_name}" + _ComponentNameHook: TypeAlias = Callable[[str, types.Implementation], str] + _component_name_hook: _ComponentNameHook | None + + def __init__( + self, + exit_stack: contextlib.AsyncExitStack | None = None, + component_name_hook: _ComponentNameHook | None = None, + ) -> None: + """Initializes the MCP client.""" + + self._tools = {} + self._resources = {} + self._prompts = {} + + self._sessions = {} + self._tool_to_session = {} + if exit_stack is None: + self._exit_stack = contextlib.AsyncExitStack() + self._owns_exit_stack = True + else: + self._exit_stack = exit_stack + self._owns_exit_stack = False + self._session_exit_stacks = {} + self._component_name_hook = component_name_hook + + async def __aenter__(self) -> Self: # pragma: no cover + # Enter the exit stack only if we created it ourselves + if self._owns_exit_stack: + await self._exit_stack.__aenter__() + return self + + async def __aexit__( + self, + _exc_type: type[BaseException] | None, + _exc_val: BaseException | None, + _exc_tb: TracebackType | None, + ) -> bool | None: # pragma: no cover + """Closes session exit stacks and main exit stack upon completion.""" + + # Only close the main exit stack if we created it + if self._owns_exit_stack: + await self._exit_stack.aclose() + + # Concurrently close session stacks. + async with anyio.create_task_group() as tg: + for exit_stack in self._session_exit_stacks.values(): + tg.start_soon(exit_stack.aclose) + + @property + def sessions(self) -> list[mcp.ClientSession]: + """Returns the list of sessions being managed.""" + return list(self._sessions.keys()) # pragma: no cover + + @property + def prompts(self) -> dict[str, types.Prompt]: + """Returns the prompts as a dictionary of names to prompts.""" + return self._prompts + + @property + def resources(self) -> dict[str, types.Resource]: + """Returns the resources as a dictionary of names to resources.""" + return self._resources + + @property + def tools(self) -> dict[str, types.Tool]: + """Returns the tools as a dictionary of names to tools.""" + return self._tools + + @overload + async def call_tool( + self, + name: str, + arguments: dict[str, Any], + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + meta: dict[str, Any] | None = None, + ) -> types.CallToolResult: ... + + @overload + @deprecated("The 'args' parameter is deprecated. Use 'arguments' instead.") + async def call_tool( + self, + name: str, + *, + args: dict[str, Any], + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + meta: dict[str, Any] | None = None, + ) -> types.CallToolResult: ... + + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + meta: dict[str, Any] | None = None, + args: dict[str, Any] | None = None, + ) -> types.CallToolResult: + """Executes a tool given its name and arguments.""" + session = self._tool_to_session[name] + session_tool_name = self.tools[name].name + return await session.call_tool( + session_tool_name, + arguments if args is None else args, + read_timeout_seconds=read_timeout_seconds, + progress_callback=progress_callback, + meta=meta, + ) + + async def disconnect_from_server(self, session: mcp.ClientSession) -> None: + """Disconnects from a single MCP server.""" + + session_known_for_components = session in self._sessions + session_known_for_stack = session in self._session_exit_stacks + + if not session_known_for_components and not session_known_for_stack: + raise McpError( + types.ErrorData( + code=types.INVALID_PARAMS, + message="Provided session is not managed or already disconnected.", + ) + ) + + if session_known_for_components: # pragma: no cover + component_names = self._sessions.pop(session) # Pop from _sessions tracking + + # Remove prompts associated with the session. + for name in component_names.prompts: + if name in self._prompts: + del self._prompts[name] + # Remove resources associated with the session. + for name in component_names.resources: + if name in self._resources: + del self._resources[name] + # Remove tools associated with the session. + for name in component_names.tools: + if name in self._tools: + del self._tools[name] + if name in self._tool_to_session: + del self._tool_to_session[name] + + # Clean up the session's resources via its dedicated exit stack + if session_known_for_stack: + session_stack_to_close = self._session_exit_stacks.pop(session) # pragma: no cover + await session_stack_to_close.aclose() # pragma: no cover + + async def connect_with_session( + self, server_info: types.Implementation, session: mcp.ClientSession + ) -> mcp.ClientSession: + """Connects to a single MCP server.""" + await self._aggregate_components(server_info, session) + return session + + async def connect_to_server( + self, + server_params: ServerParameters, + session_params: ClientSessionParameters | None = None, + ) -> mcp.ClientSession: + """Connects to a single MCP server.""" + server_info, session = await self._establish_session(server_params, session_params or ClientSessionParameters()) + return await self.connect_with_session(server_info, session) + + async def _establish_session( + self, + server_params: ServerParameters, + session_params: ClientSessionParameters, + ) -> tuple[types.Implementation, mcp.ClientSession]: + """Establish a client session to an MCP server.""" + + session_stack = contextlib.AsyncExitStack() + try: + # Create read and write streams that facilitate io with the server. + if isinstance(server_params, StdioServerParameters): + client = mcp.stdio_client(server_params) + read, write = await session_stack.enter_async_context(client) + elif isinstance(server_params, SseServerParameters): + client = sse_client( + url=server_params.url, + headers=server_params.headers, + timeout=server_params.timeout, + sse_read_timeout=server_params.sse_read_timeout, + ) + read, write = await session_stack.enter_async_context(client) + else: + httpx_client = create_mcp_http_client( + headers=server_params.headers, + timeout=httpx.Timeout( + server_params.timeout, + read=server_params.sse_read_timeout, + ), + ) + await session_stack.enter_async_context(httpx_client) + + client = streamable_http_client( + url=server_params.url, + http_client=httpx_client, + terminate_on_close=server_params.terminate_on_close, + ) + read, write, _ = await session_stack.enter_async_context(client) + + session = await session_stack.enter_async_context( + mcp.ClientSession( + read, + write, + read_timeout_seconds=session_params.read_timeout_seconds, + sampling_callback=session_params.sampling_callback, + elicitation_callback=session_params.elicitation_callback, + list_roots_callback=session_params.list_roots_callback, + logging_callback=session_params.logging_callback, + message_handler=session_params.message_handler, + client_info=session_params.client_info, + ) + ) + + result = await session.initialize() + + # Session successfully initialized. + # Store its stack and register the stack with the main group stack. + self._session_exit_stacks[session] = session_stack + # session_stack itself becomes a resource managed by the + # main _exit_stack. + await self._exit_stack.enter_async_context(session_stack) + + return result.serverInfo, session + except Exception: # pragma: no cover + # If anything during this setup fails, ensure the session-specific + # stack is closed. + await session_stack.aclose() + raise + + async def _aggregate_components(self, server_info: types.Implementation, session: mcp.ClientSession) -> None: + """Aggregates prompts, resources, and tools from a given session.""" + + # Create a reverse index so we can find all prompts, resources, and + # tools belonging to this session. Used for removing components from + # the session group via self.disconnect_from_server. + component_names = self._ComponentNames() + + # Temporary components dicts. We do not want to modify the aggregate + # lists in case of an intermediate failure. + prompts_temp: dict[str, types.Prompt] = {} + resources_temp: dict[str, types.Resource] = {} + tools_temp: dict[str, types.Tool] = {} + tool_to_session_temp: dict[str, mcp.ClientSession] = {} + + # Query the server for its prompts and aggregate to list. + try: + prompts = (await session.list_prompts()).prompts + for prompt in prompts: + name = self._component_name(prompt.name, server_info) + prompts_temp[name] = prompt + component_names.prompts.add(name) + except McpError as err: # pragma: no cover + logging.warning(f"Could not fetch prompts: {err}") + + # Query the server for its resources and aggregate to list. + try: + resources = (await session.list_resources()).resources + for resource in resources: + name = self._component_name(resource.name, server_info) + resources_temp[name] = resource + component_names.resources.add(name) + except McpError as err: # pragma: no cover + logging.warning(f"Could not fetch resources: {err}") + + # Query the server for its tools and aggregate to list. + try: + tools = (await session.list_tools()).tools + for tool in tools: + name = self._component_name(tool.name, server_info) + tools_temp[name] = tool + tool_to_session_temp[name] = session + component_names.tools.add(name) + except McpError as err: # pragma: no cover + logging.warning(f"Could not fetch tools: {err}") + + # Clean up exit stack for session if we couldn't retrieve anything + # from the server. + if not any((prompts_temp, resources_temp, tools_temp)): + del self._session_exit_stacks[session] # pragma: no cover + + # Check for duplicates. + matching_prompts = prompts_temp.keys() & self._prompts.keys() + if matching_prompts: + raise McpError( # pragma: no cover + types.ErrorData( + code=types.INVALID_PARAMS, + message=f"{matching_prompts} already exist in group prompts.", + ) + ) + matching_resources = resources_temp.keys() & self._resources.keys() + if matching_resources: + raise McpError( # pragma: no cover + types.ErrorData( + code=types.INVALID_PARAMS, + message=f"{matching_resources} already exist in group resources.", + ) + ) + matching_tools = tools_temp.keys() & self._tools.keys() + if matching_tools: + raise McpError( + types.ErrorData( + code=types.INVALID_PARAMS, + message=f"{matching_tools} already exist in group tools.", + ) + ) + + # Aggregate components. + self._sessions[session] = component_names + self._prompts.update(prompts_temp) + self._resources.update(resources_temp) + self._tools.update(tools_temp) + self._tool_to_session.update(tool_to_session_temp) + + def _component_name(self, name: str, server_info: types.Implementation) -> str: + if self._component_name_hook: + return self._component_name_hook(name, server_info) + return name diff --git a/src/mcp/client/sse.py b/src/mcp/client/sse.py index 4f6241a720..4b0bbbc1e7 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -1,15 +1,19 @@ import logging +from collections.abc import Callable from contextlib import asynccontextmanager from typing import Any -from urllib.parse import urljoin, urlparse +from urllib.parse import parse_qs, urljoin, urlparse import anyio import httpx from anyio.abc import TaskStatus from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from httpx_sse import aconnect_sse +from httpx_sse._exceptions import SSEError import mcp.types as types +from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client +from mcp.shared.message import SessionMessage logger = logging.getLogger(__name__) @@ -18,37 +22,54 @@ def remove_request_params(url: str) -> str: return urljoin(url, urlparse(url).path) +def _extract_session_id_from_endpoint(endpoint_url: str) -> str | None: + query_params = parse_qs(urlparse(endpoint_url).query) + return query_params.get("sessionId", [None])[0] or query_params.get("session_id", [None])[0] + + @asynccontextmanager async def sse_client( url: str, headers: dict[str, Any] | None = None, - timeout: float = 5, - sse_read_timeout: float = 60 * 5, + timeout: float = 5.0, + sse_read_timeout: float = 300.0, + httpx_client_factory: McpHttpClientFactory = create_mcp_http_client, + auth: httpx.Auth | None = None, + on_session_created: Callable[[str], None] | None = None, ): """ Client transport for SSE. `sse_read_timeout` determines how long (in seconds) the client will wait for a new event before disconnecting. All other HTTP operations are controlled by `timeout`. + + Args: + url: The SSE endpoint URL. + headers: Optional headers to include in requests. + timeout: HTTP timeout for regular operations (in seconds). + sse_read_timeout: Timeout for SSE read operations (in seconds). + auth: Optional HTTPX authentication handler. + on_session_created: Optional callback invoked with the session ID when received. """ - read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception] - read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception] + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] + read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - write_stream: MemoryObjectSendStream[types.JSONRPCMessage] - write_stream_reader: MemoryObjectReceiveStream[types.JSONRPCMessage] + write_stream: MemoryObjectSendStream[SessionMessage] + write_stream_reader: MemoryObjectReceiveStream[SessionMessage] read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) async with anyio.create_task_group() as tg: try: - logger.info(f"Connecting to SSE endpoint: {remove_request_params(url)}") - async with httpx.AsyncClient(headers=headers) as client: + logger.debug(f"Connecting to SSE endpoint: {remove_request_params(url)}") + async with httpx_client_factory( + headers=headers, auth=auth, timeout=httpx.Timeout(timeout, read=sse_read_timeout) + ) as client: async with aconnect_sse( client, "GET", url, - timeout=httpx.Timeout(timeout, read=sse_read_timeout), ) as event_source: event_source.response.raise_for_status() logger.debug("SSE connection established") @@ -57,84 +78,81 @@ async def sse_reader( task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED, ): try: - async for sse in event_source.aiter_sse(): + async for sse in event_source.aiter_sse(): # pragma: no branch logger.debug(f"Received SSE event: {sse.event}") match sse.event: case "endpoint": endpoint_url = urljoin(url, sse.data) - logger.info( - f"Received endpoint URL: {endpoint_url}" - ) + logger.debug(f"Received endpoint URL: {endpoint_url}") url_parsed = urlparse(url) endpoint_parsed = urlparse(endpoint_url) - if ( + if ( # pragma: no cover url_parsed.netloc != endpoint_parsed.netloc - or url_parsed.scheme - != endpoint_parsed.scheme + or url_parsed.scheme != endpoint_parsed.scheme ): - error_msg = ( - "Endpoint origin does not match " - f"connection origin: {endpoint_url}" + error_msg = ( # pragma: no cover + f"Endpoint origin does not match connection origin: {endpoint_url}" ) - logger.error(error_msg) - raise ValueError(error_msg) + logger.error(error_msg) # pragma: no cover + raise ValueError(error_msg) # pragma: no cover + + if on_session_created: + session_id = _extract_session_id_from_endpoint(endpoint_url) + if session_id: + on_session_created(session_id) task_status.started(endpoint_url) case "message": + # Skip empty data (keep-alive pings) + if not sse.data: + continue try: message = types.JSONRPCMessage.model_validate_json( # noqa: E501 sse.data ) - logger.debug( - f"Received server message: {message}" - ) - except Exception as exc: - logger.error( - f"Error parsing server message: {exc}" - ) - await read_stream_writer.send(exc) - continue - - await read_stream_writer.send(message) - case _: - logger.warning( - f"Unknown SSE event: {sse.event}" - ) - except Exception as exc: - logger.error(f"Error in sse_reader: {exc}") - await read_stream_writer.send(exc) + logger.debug(f"Received server message: {message}") + except Exception as exc: # pragma: no cover + logger.exception("Error parsing server message") # pragma: no cover + await read_stream_writer.send(exc) # pragma: no cover + continue # pragma: no cover + + session_message = SessionMessage(message) + await read_stream_writer.send(session_message) + case _: # pragma: no cover + logger.warning(f"Unknown SSE event: {sse.event}") # pragma: no cover + except SSEError as sse_exc: # pragma: no cover + logger.exception("Encountered SSE exception") # pragma: no cover + raise sse_exc # pragma: no cover + except Exception as exc: # pragma: no cover + logger.exception("Error in sse_reader") # pragma: no cover + await read_stream_writer.send(exc) # pragma: no cover finally: await read_stream_writer.aclose() async def post_writer(endpoint_url: str): try: async with write_stream_reader: - async for message in write_stream_reader: - logger.debug(f"Sending client message: {message}") + async for session_message in write_stream_reader: + logger.debug(f"Sending client message: {session_message}") response = await client.post( endpoint_url, - json=message.model_dump( + json=session_message.message.model_dump( by_alias=True, mode="json", exclude_none=True, ), ) response.raise_for_status() - logger.debug( - "Client message sent successfully: " - f"{response.status_code}" - ) - except Exception as exc: - logger.error(f"Error in post_writer: {exc}") + logger.debug(f"Client message sent successfully: {response.status_code}") + except Exception: # pragma: no cover + logger.exception("Error in post_writer") # pragma: no cover finally: await write_stream.aclose() endpoint_url = await tg.start(sse_reader) - logger.info( - f"Starting post writer with endpoint URL: {endpoint_url}" - ) + logger.debug(f"Starting post writer with endpoint URL: {endpoint_url}") tg.start_soon(post_writer, endpoint_url) try: diff --git a/src/mcp/client/stdio/__init__.py b/src/mcp/client/stdio/__init__.py index 83de57a2b9..0d76bb958b 100644 --- a/src/mcp/client/stdio/__init__.py +++ b/src/mcp/client/stdio/__init__.py @@ -1,3 +1,4 @@ +import logging import os import sys from contextlib import asynccontextmanager @@ -6,17 +7,22 @@ import anyio import anyio.lowlevel +from anyio.abc import Process from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from anyio.streams.text import TextReceiveStream from pydantic import BaseModel, Field import mcp.types as types - -from .win32 import ( +from mcp.os.posix.utilities import terminate_posix_process_tree +from mcp.os.win32.utilities import ( + FallbackProcess, create_windows_process, get_windows_executable_command, - terminate_windows_process, + terminate_windows_process_tree, ) +from mcp.shared.message import SessionMessage + +logger = logging.getLogger(__name__) # Environment variables to inherit by default DEFAULT_INHERITED_ENV_VARS = ( @@ -26,6 +32,7 @@ "HOMEPATH", "LOCALAPPDATA", "PATH", + "PATHEXT", "PROCESSOR_ARCHITECTURE", "SYSTEMDRIVE", "SYSTEMROOT", @@ -37,6 +44,9 @@ else ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"] ) +# Timeout for process termination before falling back to force kill +PROCESS_TERMINATION_TIMEOUT = 2.0 + def get_default_environment() -> dict[str, str]: """ @@ -48,11 +58,11 @@ def get_default_environment() -> dict[str, str]: for key in DEFAULT_INHERITED_ENV_VARS: value = os.environ.get(key) if value is None: - continue + continue # pragma: no cover - if value.startswith("()"): + if value.startswith("()"): # pragma: no cover # Skip functions, which are a security risk - continue + continue # pragma: no cover env[key] = value @@ -98,29 +108,33 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder Client transport for stdio: this will connect to a server by spawning a process and communicating with it over stdin/stdout. """ - read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception] - read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception] + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] + read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - write_stream: MemoryObjectSendStream[types.JSONRPCMessage] - write_stream_reader: MemoryObjectReceiveStream[types.JSONRPCMessage] + write_stream: MemoryObjectSendStream[SessionMessage] + write_stream_reader: MemoryObjectReceiveStream[SessionMessage] read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) - command = _get_executable_command(server.command) - - # Open process with stderr piped for capture - process = await _create_platform_compatible_process( - command=command, - args=server.args, - env=( - {**get_default_environment(), **server.env} - if server.env is not None - else get_default_environment() - ), - errlog=errlog, - cwd=server.cwd, - ) + try: + command = _get_executable_command(server.command) + + # Open process with stderr piped for capture + process = await _create_platform_compatible_process( + command=command, + args=server.args, + env=({**get_default_environment(), **server.env} if server.env is not None else get_default_environment()), + errlog=errlog, + cwd=server.cwd, + ) + except OSError: + # Clean up streams if process creation fails + await read_stream.aclose() + await write_stream.aclose() + await read_stream_writer.aclose() + await write_stream_reader.aclose() + raise async def stdout_reader(): assert process.stdout, "Opened process is missing stdout" @@ -139,12 +153,14 @@ async def stdout_reader(): for line in lines: try: message = types.JSONRPCMessage.model_validate_json(line) - except Exception as exc: + except Exception as exc: # pragma: no cover + logger.exception("Failed to parse JSONRPC message from server") await read_stream_writer.send(exc) continue - await read_stream_writer.send(message) - except anyio.ClosedResourceError: + session_message = SessionMessage(message) + await read_stream_writer.send(session_message) + except anyio.ClosedResourceError: # pragma: no cover await anyio.lowlevel.checkpoint() async def stdin_writer(): @@ -152,15 +168,15 @@ async def stdin_writer(): try: async with write_stream_reader: - async for message in write_stream_reader: - json = message.model_dump_json(by_alias=True, exclude_none=True) + async for session_message in write_stream_reader: + json = session_message.message.model_dump_json(by_alias=True, exclude_none=True) await process.stdin.send( (json + "\n").encode( encoding=server.encoding, errors=server.encoding_error_handler, ) ) - except anyio.ClosedResourceError: + except anyio.ClosedResourceError: # pragma: no cover await anyio.lowlevel.checkpoint() async with ( @@ -172,11 +188,32 @@ async def stdin_writer(): try: yield read_stream, write_stream finally: - # Clean up process to prevent any dangling orphaned processes - if sys.platform == "win32": - await terminate_windows_process(process) - else: - process.terminate() + # MCP spec: stdio shutdown sequence + # 1. Close input stream to server + # 2. Wait for server to exit, or send SIGTERM if it doesn't exit in time + # 3. Send SIGKILL if still not exited + if process.stdin: # pragma: no branch + try: + await process.stdin.aclose() + except Exception: # pragma: no cover + # stdin might already be closed, which is fine + pass + + try: + # Give the process time to exit gracefully after stdin closes + with anyio.fail_after(PROCESS_TERMINATION_TIMEOUT): + await process.wait() + except TimeoutError: + # Process didn't exit from stdin closure, use platform-specific termination + # which handles SIGTERM -> SIGKILL escalation + await _terminate_process_tree(process) + except ProcessLookupError: # pragma: no cover + # Process already exited, which is fine + pass + await read_stream.aclose() + await write_stream.aclose() + await read_stream_writer.aclose() + await write_stream_reader.aclose() def _get_executable_command(command: str) -> str: @@ -189,10 +226,10 @@ def _get_executable_command(command: str) -> str: Returns: str: Platform-appropriate command """ - if sys.platform == "win32": + if sys.platform == "win32": # pragma: no cover return get_windows_executable_command(command) else: - return command + return command # pragma: no cover async def _create_platform_compatible_process( @@ -204,13 +241,38 @@ async def _create_platform_compatible_process( ): """ Creates a subprocess in a platform-compatible way. - Returns a process handle. + + Unix: Creates process in a new session/process group for killpg support + Windows: Creates process in a Job Object for reliable child termination """ - if sys.platform == "win32": + if sys.platform == "win32": # pragma: no cover process = await create_windows_process(command, args, env, errlog, cwd) else: process = await anyio.open_process( - [command, *args], env=env, stderr=errlog, cwd=cwd - ) + [command, *args], + env=env, + stderr=errlog, + cwd=cwd, + start_new_session=True, + ) # pragma: no cover return process + + +async def _terminate_process_tree(process: Process | FallbackProcess, timeout_seconds: float = 2.0) -> None: + """ + Terminate a process and all its children using platform-specific methods. + + Unix: Uses os.killpg() for atomic process group termination + Windows: Uses Job Objects via pywin32 for reliable child process cleanup + + Args: + process: The process to terminate + timeout_seconds: Timeout in seconds before force killing (default: 2.0) + """ + if sys.platform == "win32": # pragma: no cover + await terminate_windows_process_tree(process, timeout_seconds) + else: # pragma: no cover + # FallbackProcess should only be used for Windows compatibility + assert isinstance(process, Process) + await terminate_posix_process_tree(process, timeout_seconds) diff --git a/src/mcp/client/stdio/win32.py b/src/mcp/client/stdio/win32.py deleted file mode 100644 index 825a0477d4..0000000000 --- a/src/mcp/client/stdio/win32.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -Windows-specific functionality for stdio client operations. -""" - -import shutil -import subprocess -import sys -from pathlib import Path -from typing import TextIO - -import anyio -from anyio.abc import Process - - -def get_windows_executable_command(command: str) -> str: - """ - Get the correct executable command normalized for Windows. - - On Windows, commands might exist with specific extensions (.exe, .cmd, etc.) - that need to be located for proper execution. - - Args: - command: Base command (e.g., 'uvx', 'npx') - - Returns: - str: Windows-appropriate command path - """ - try: - # First check if command exists in PATH as-is - if command_path := shutil.which(command): - return command_path - - # Check for Windows-specific extensions - for ext in [".cmd", ".bat", ".exe", ".ps1"]: - ext_version = f"{command}{ext}" - if ext_path := shutil.which(ext_version): - return ext_path - - # For regular commands or if we couldn't find special versions - return command - except OSError: - # Handle file system errors during path resolution - # (permissions, broken symlinks, etc.) - return command - - -async def create_windows_process( - command: str, - args: list[str], - env: dict[str, str] | None = None, - errlog: TextIO = sys.stderr, - cwd: Path | str | None = None, -): - """ - Creates a subprocess in a Windows-compatible way. - - Windows processes need special handling for console windows and - process creation flags. - - Args: - command: The command to execute - args: Command line arguments - env: Environment variables - errlog: Where to send stderr output - cwd: Working directory for the process - - Returns: - A process handle - """ - try: - # Try with Windows-specific flags to hide console window - process = await anyio.open_process( - [command, *args], - env=env, - # Ensure we don't create console windows for each process - creationflags=subprocess.CREATE_NO_WINDOW # type: ignore - if hasattr(subprocess, "CREATE_NO_WINDOW") - else 0, - stderr=errlog, - cwd=cwd, - ) - return process - except Exception: - # Don't raise, let's try to create the process without creation flags - process = await anyio.open_process( - [command, *args], env=env, stderr=errlog, cwd=cwd - ) - return process - - -async def terminate_windows_process(process: Process): - """ - Terminate a Windows process. - - Note: On Windows, terminating a process with process.terminate() doesn't - always guarantee immediate process termination. - So we give it 2s to exit, or we call process.kill() - which sends a SIGKILL equivalent signal. - - Args: - process: The process to terminate - """ - try: - process.terminate() - with anyio.fail_after(2.0): - await process.wait() - except TimeoutError: - # Force kill if it doesn't terminate - process.kill() diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py new file mode 100644 index 0000000000..22645d3ba5 --- /dev/null +++ b/src/mcp/client/streamable_http.py @@ -0,0 +1,722 @@ +""" +StreamableHTTP Client Transport Module + +This module implements the StreamableHTTP transport for MCP clients, +providing support for HTTP POST requests with optional SSE streaming responses +and session management. +""" + +import contextlib +import logging +from collections.abc import AsyncGenerator, Awaitable, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, overload +from warnings import warn + +import anyio +import httpx +from anyio.abc import TaskGroup +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from httpx_sse import EventSource, ServerSentEvent, aconnect_sse +from typing_extensions import deprecated + +from mcp.shared._httpx_utils import ( + McpHttpClientFactory, + create_mcp_http_client, +) +from mcp.shared.message import ClientMessageMetadata, SessionMessage +from mcp.types import ( + ErrorData, + InitializeResult, + JSONRPCError, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + RequestId, +) + +logger = logging.getLogger(__name__) + + +SessionMessageOrError = SessionMessage | Exception +StreamWriter = MemoryObjectSendStream[SessionMessageOrError] +StreamReader = MemoryObjectReceiveStream[SessionMessage] +GetSessionIdCallback = Callable[[], str | None] + +MCP_SESSION_ID = "mcp-session-id" +MCP_PROTOCOL_VERSION = "mcp-protocol-version" +LAST_EVENT_ID = "last-event-id" + +# Reconnection defaults +DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry +MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up +CONTENT_TYPE = "content-type" +ACCEPT = "accept" + + +JSON = "application/json" +SSE = "text/event-stream" + +# Sentinel value for detecting unset optional parameters +_UNSET = object() + + +class StreamableHTTPError(Exception): + """Base exception for StreamableHTTP transport errors.""" + + +class ResumptionError(StreamableHTTPError): + """Raised when resumption request is invalid.""" + + +@dataclass +class RequestContext: + """Context for a request operation.""" + + client: httpx.AsyncClient + session_id: str | None + session_message: SessionMessage + metadata: ClientMessageMetadata | None + read_stream_writer: StreamWriter + headers: dict[str, str] | None = None # Deprecated - no longer used + sse_read_timeout: float | None = None # Deprecated - no longer used + + +class StreamableHTTPTransport: + """StreamableHTTP client transport implementation.""" + + @overload + def __init__(self, url: str) -> None: ... + + @overload + @deprecated( + "Parameters headers, timeout, sse_read_timeout, and auth are deprecated. " + "Configure these on the httpx.AsyncClient instead." + ) + def __init__( + self, + url: str, + headers: dict[str, str] | None = None, + timeout: float = 30.0, + sse_read_timeout: float = 300.0, + auth: httpx.Auth | None = None, + ) -> None: ... + + def __init__( + self, + url: str, + headers: Any = _UNSET, + timeout: Any = _UNSET, + sse_read_timeout: Any = _UNSET, + auth: Any = _UNSET, + ) -> None: + """Initialize the StreamableHTTP transport. + + Args: + url: The endpoint URL. + headers: Optional headers to include in requests. + timeout: HTTP timeout for regular operations (in seconds). + sse_read_timeout: Timeout for SSE read operations (in seconds). + auth: Optional HTTPX authentication handler. + """ + # Check for deprecated parameters and issue runtime warning + deprecated_params: list[str] = [] + if headers is not _UNSET: + deprecated_params.append("headers") + if timeout is not _UNSET: + deprecated_params.append("timeout") + if sse_read_timeout is not _UNSET: + deprecated_params.append("sse_read_timeout") + if auth is not _UNSET: + deprecated_params.append("auth") + + if deprecated_params: + warn( + f"Parameters {', '.join(deprecated_params)} are deprecated and will be ignored. " + "Configure these on the httpx.AsyncClient instead.", + DeprecationWarning, + stacklevel=2, + ) + + self.url = url + self.session_id = None + self.protocol_version = None + + def _prepare_headers(self) -> dict[str, str]: + """Build MCP-specific request headers. + + These headers will be merged with the httpx.AsyncClient's default headers, + with these MCP-specific headers taking precedence. + """ + headers: dict[str, str] = {} + # Add MCP protocol headers + headers[ACCEPT] = f"{JSON}, {SSE}" + headers[CONTENT_TYPE] = JSON + # Add session headers if available + if self.session_id: + headers[MCP_SESSION_ID] = self.session_id + if self.protocol_version: + headers[MCP_PROTOCOL_VERSION] = self.protocol_version + return headers + + def _is_initialization_request(self, message: JSONRPCMessage) -> bool: + """Check if the message is an initialization request.""" + return isinstance(message.root, JSONRPCRequest) and message.root.method == "initialize" + + def _is_initialized_notification(self, message: JSONRPCMessage) -> bool: + """Check if the message is an initialized notification.""" + return isinstance(message.root, JSONRPCNotification) and message.root.method == "notifications/initialized" + + def _maybe_extract_session_id_from_response( + self, + response: httpx.Response, + ) -> None: + """Extract and store session ID from response headers.""" + new_session_id = response.headers.get(MCP_SESSION_ID) + if new_session_id: + self.session_id = new_session_id + logger.info(f"Received session ID: {self.session_id}") + + def _maybe_extract_protocol_version_from_message( + self, + message: JSONRPCMessage, + ) -> None: + """Extract protocol version from initialization response message.""" + if isinstance(message.root, JSONRPCResponse) and message.root.result: # pragma: no branch + try: + # Parse the result as InitializeResult for type safety + init_result = InitializeResult.model_validate(message.root.result) + self.protocol_version = str(init_result.protocolVersion) + logger.info(f"Negotiated protocol version: {self.protocol_version}") + except Exception as exc: # pragma: no cover + logger.warning( + f"Failed to parse initialization response as InitializeResult: {exc}" + ) # pragma: no cover + logger.warning(f"Raw result: {message.root.result}") + + async def _handle_sse_event( + self, + sse: ServerSentEvent, + read_stream_writer: StreamWriter, + original_request_id: RequestId | None = None, + resumption_callback: Callable[[str], Awaitable[None]] | None = None, + is_initialization: bool = False, + ) -> bool: + """Handle an SSE event, returning True if the response is complete.""" + if sse.event == "message": + # Handle priming events (empty data with ID) for resumability + if not sse.data: + # Call resumption callback for priming events that have an ID + if sse.id and resumption_callback: + await resumption_callback(sse.id) + return False + try: + message = JSONRPCMessage.model_validate_json(sse.data) + logger.debug(f"SSE message: {message}") + + # Extract protocol version from initialization response + if is_initialization: + self._maybe_extract_protocol_version_from_message(message) + + # If this is a response and we have original_request_id, replace it + if original_request_id is not None and isinstance(message.root, JSONRPCResponse | JSONRPCError): + message.root.id = original_request_id + + session_message = SessionMessage(message) + await read_stream_writer.send(session_message) + + # Call resumption token callback if we have an ID + if sse.id and resumption_callback: + await resumption_callback(sse.id) + + # If this is a response or error return True indicating completion + # Otherwise, return False to continue listening + return isinstance(message.root, JSONRPCResponse | JSONRPCError) + + except Exception as exc: # pragma: no cover + logger.exception("Error parsing SSE message") + await read_stream_writer.send(exc) + return False + else: # pragma: no cover + logger.warning(f"Unknown SSE event: {sse.event}") + return False + + async def handle_get_stream( + self, + client: httpx.AsyncClient, + read_stream_writer: StreamWriter, + ) -> None: + """Handle GET stream for server-initiated messages with auto-reconnect.""" + last_event_id: str | None = None + retry_interval_ms: int | None = None + attempt: int = 0 + + while attempt < MAX_RECONNECTION_ATTEMPTS: # pragma: no branch + try: + if not self.session_id: + return + + headers = self._prepare_headers() + if last_event_id: + headers[LAST_EVENT_ID] = last_event_id # pragma: no cover + + async with aconnect_sse( + client, + "GET", + self.url, + headers=headers, + ) as event_source: + event_source.response.raise_for_status() + logger.debug("GET SSE connection established") + + async for sse in event_source.aiter_sse(): + # Track last event ID for reconnection + if sse.id: + last_event_id = sse.id # pragma: no cover + # Track retry interval from server + if sse.retry is not None: + retry_interval_ms = sse.retry # pragma: no cover + + await self._handle_sse_event(sse, read_stream_writer) + + # Stream ended normally (server closed) - reset attempt counter + attempt = 0 + + except Exception as exc: # pragma: no cover + logger.debug(f"GET stream error: {exc}") + attempt += 1 + + if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover + logger.debug(f"GET stream max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded") + return + + # Wait before reconnecting + delay_ms = retry_interval_ms if retry_interval_ms is not None else DEFAULT_RECONNECTION_DELAY_MS + logger.info(f"GET stream disconnected, reconnecting in {delay_ms}ms...") + await anyio.sleep(delay_ms / 1000.0) + + async def _handle_resumption_request(self, ctx: RequestContext) -> None: + """Handle a resumption request using GET with SSE.""" + headers = self._prepare_headers() + if ctx.metadata and ctx.metadata.resumption_token: + headers[LAST_EVENT_ID] = ctx.metadata.resumption_token + else: + raise ResumptionError("Resumption request requires a resumption token") # pragma: no cover + + # Extract original request ID to map responses + original_request_id = None + if isinstance(ctx.session_message.message.root, JSONRPCRequest): # pragma: no branch + original_request_id = ctx.session_message.message.root.id + + async with aconnect_sse( + ctx.client, + "GET", + self.url, + headers=headers, + ) as event_source: + event_source.response.raise_for_status() + logger.debug("Resumption GET SSE connection established") + + async for sse in event_source.aiter_sse(): # pragma: no branch + is_complete = await self._handle_sse_event( + sse, + ctx.read_stream_writer, + original_request_id, + ctx.metadata.on_resumption_token_update if ctx.metadata else None, + ) + if is_complete: + await event_source.response.aclose() + break + + async def _handle_post_request(self, ctx: RequestContext) -> None: + """Handle a POST request with response processing.""" + headers = self._prepare_headers() + message = ctx.session_message.message + is_initialization = self._is_initialization_request(message) + + async with ctx.client.stream( + "POST", + self.url, + json=message.model_dump(by_alias=True, mode="json", exclude_none=True), + headers=headers, + ) as response: + if response.status_code == 202: + logger.debug("Received 202 Accepted") + return + + if response.status_code == 404: # pragma: no branch + if isinstance(message.root, JSONRPCRequest): + await self._send_session_terminated_error( # pragma: no cover + ctx.read_stream_writer, # pragma: no cover + message.root.id, # pragma: no cover + ) # pragma: no cover + return # pragma: no cover + + response.raise_for_status() + if is_initialization: + self._maybe_extract_session_id_from_response(response) + + # Per https://modelcontextprotocol.io/specification/2025-06-18/basic#notifications: + # The server MUST NOT send a response to notifications. + if isinstance(message.root, JSONRPCRequest): + content_type = response.headers.get(CONTENT_TYPE, "").lower() + if content_type.startswith(JSON): + await self._handle_json_response(response, ctx.read_stream_writer, is_initialization) + elif content_type.startswith(SSE): + await self._handle_sse_response(response, ctx, is_initialization) + else: + await self._handle_unexpected_content_type( # pragma: no cover + content_type, # pragma: no cover + ctx.read_stream_writer, # pragma: no cover + ) # pragma: no cover + + async def _handle_json_response( + self, + response: httpx.Response, + read_stream_writer: StreamWriter, + is_initialization: bool = False, + ) -> None: + """Handle JSON response from the server.""" + try: + content = await response.aread() + message = JSONRPCMessage.model_validate_json(content) + + # Extract protocol version from initialization response + if is_initialization: + self._maybe_extract_protocol_version_from_message(message) + + session_message = SessionMessage(message) + await read_stream_writer.send(session_message) + except Exception as exc: # pragma: no cover + logger.exception("Error parsing JSON response") + await read_stream_writer.send(exc) + + async def _handle_sse_response( + self, + response: httpx.Response, + ctx: RequestContext, + is_initialization: bool = False, + ) -> None: + """Handle SSE response from the server.""" + last_event_id: str | None = None + retry_interval_ms: int | None = None + + try: + event_source = EventSource(response) + async for sse in event_source.aiter_sse(): # pragma: no branch + # Track last event ID for potential reconnection + if sse.id: + last_event_id = sse.id + + # Track retry interval from server + if sse.retry is not None: + retry_interval_ms = sse.retry + + is_complete = await self._handle_sse_event( + sse, + ctx.read_stream_writer, + resumption_callback=(ctx.metadata.on_resumption_token_update if ctx.metadata else None), + is_initialization=is_initialization, + ) + # If the SSE event indicates completion, like returning respose/error + # break the loop + if is_complete: + await response.aclose() + return # Normal completion, no reconnect needed + except Exception as e: # pragma: no cover + logger.debug(f"SSE stream ended: {e}") + + # Stream ended without response - reconnect if we received an event with ID + if last_event_id is not None: # pragma: no branch + logger.info("SSE stream disconnected, reconnecting...") + await self._handle_reconnection(ctx, last_event_id, retry_interval_ms) + + async def _handle_reconnection( + self, + ctx: RequestContext, + last_event_id: str, + retry_interval_ms: int | None = None, + attempt: int = 0, + ) -> None: + """Reconnect with Last-Event-ID to resume stream after server disconnect.""" + # Bail if max retries exceeded + if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover + logger.debug(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded") + return + + # Always wait - use server value or default + delay_ms = retry_interval_ms if retry_interval_ms is not None else DEFAULT_RECONNECTION_DELAY_MS + await anyio.sleep(delay_ms / 1000.0) + + headers = self._prepare_headers() + headers[LAST_EVENT_ID] = last_event_id + + # Extract original request ID to map responses + original_request_id = None + if isinstance(ctx.session_message.message.root, JSONRPCRequest): # pragma: no branch + original_request_id = ctx.session_message.message.root.id + + try: + async with aconnect_sse( + ctx.client, + "GET", + self.url, + headers=headers, + ) as event_source: + event_source.response.raise_for_status() + logger.info("Reconnected to SSE stream") + + # Track for potential further reconnection + reconnect_last_event_id: str = last_event_id + reconnect_retry_ms = retry_interval_ms + + async for sse in event_source.aiter_sse(): + if sse.id: # pragma: no branch + reconnect_last_event_id = sse.id + if sse.retry is not None: + reconnect_retry_ms = sse.retry + + is_complete = await self._handle_sse_event( + sse, + ctx.read_stream_writer, + original_request_id, + ctx.metadata.on_resumption_token_update if ctx.metadata else None, + ) + if is_complete: + await event_source.response.aclose() + return + + # Stream ended again without response - reconnect again (reset attempt counter) + logger.info("SSE stream disconnected, reconnecting...") + await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0) + except Exception as e: # pragma: no cover + logger.debug(f"Reconnection failed: {e}") + # Try to reconnect again if we still have an event ID + await self._handle_reconnection(ctx, last_event_id, retry_interval_ms, attempt + 1) + + async def _handle_unexpected_content_type( + self, + content_type: str, + read_stream_writer: StreamWriter, + ) -> None: # pragma: no cover + """Handle unexpected content type in response.""" + error_msg = f"Unexpected content type: {content_type}" # pragma: no cover + logger.error(error_msg) # pragma: no cover + await read_stream_writer.send(ValueError(error_msg)) # pragma: no cover + + async def _send_session_terminated_error( + self, + read_stream_writer: StreamWriter, + request_id: RequestId, + ) -> None: + """Send a session terminated error response.""" + jsonrpc_error = JSONRPCError( + jsonrpc="2.0", + id=request_id, + error=ErrorData(code=32600, message="Session terminated"), + ) + session_message = SessionMessage(JSONRPCMessage(jsonrpc_error)) + await read_stream_writer.send(session_message) + + async def post_writer( + self, + client: httpx.AsyncClient, + write_stream_reader: StreamReader, + read_stream_writer: StreamWriter, + write_stream: MemoryObjectSendStream[SessionMessage], + start_get_stream: Callable[[], None], + tg: TaskGroup, + ) -> None: + """Handle writing requests to the server.""" + try: + async with write_stream_reader: + async for session_message in write_stream_reader: + message = session_message.message + metadata = ( + session_message.metadata + if isinstance(session_message.metadata, ClientMessageMetadata) + else None + ) + + # Check if this is a resumption request + is_resumption = bool(metadata and metadata.resumption_token) + + logger.debug(f"Sending client message: {message}") + + # Handle initialized notification + if self._is_initialized_notification(message): + start_get_stream() + + ctx = RequestContext( + client=client, + session_id=self.session_id, + session_message=session_message, + metadata=metadata, + read_stream_writer=read_stream_writer, + ) + + async def handle_request_async(): + if is_resumption: + await self._handle_resumption_request(ctx) + else: + await self._handle_post_request(ctx) + + # If this is a request, start a new task to handle it + if isinstance(message.root, JSONRPCRequest): + tg.start_soon(handle_request_async) + else: + await handle_request_async() + + except Exception: + logger.exception("Error in post_writer") # pragma: no cover + finally: + await read_stream_writer.aclose() + await write_stream.aclose() + + async def terminate_session(self, client: httpx.AsyncClient) -> None: # pragma: no cover + """Terminate the session by sending a DELETE request.""" + if not self.session_id: + return + + try: + headers = self._prepare_headers() + response = await client.delete(self.url, headers=headers) + + if response.status_code == 405: + logger.debug("Server does not allow session termination") + elif response.status_code not in (200, 204): + logger.warning(f"Session termination failed: {response.status_code}") + except Exception as exc: + logger.warning(f"Session termination failed: {exc}") + + def get_session_id(self) -> str | None: + """Get the current session ID.""" + return self.session_id + + +@asynccontextmanager +async def streamable_http_client( + url: str, + *, + http_client: httpx.AsyncClient | None = None, + terminate_on_close: bool = True, +) -> AsyncGenerator[ + tuple[ + MemoryObjectReceiveStream[SessionMessage | Exception], + MemoryObjectSendStream[SessionMessage], + GetSessionIdCallback, + ], + None, +]: + """ + Client transport for StreamableHTTP. + + Args: + url: The MCP server endpoint URL. + http_client: Optional pre-configured httpx.AsyncClient. If None, a default + client with recommended MCP timeouts will be created. To configure headers, + authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here. + terminate_on_close: If True, send a DELETE request to terminate the session + when the context exits. + + Yields: + Tuple containing: + - read_stream: Stream for reading messages from the server + - write_stream: Stream for sending messages to the server + - get_session_id_callback: Function to retrieve the current session ID + + Example: + See examples/snippets/clients/ for usage patterns. + """ + read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](0) + write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0) + + # Determine if we need to create and manage the client + client_provided = http_client is not None + client = http_client + + if client is None: + # Create default client with recommended MCP timeouts + client = create_mcp_http_client() + + transport = StreamableHTTPTransport(url) + + async with anyio.create_task_group() as tg: + try: + logger.debug(f"Connecting to StreamableHTTP endpoint: {url}") + + async with contextlib.AsyncExitStack() as stack: + # Only manage client lifecycle if we created it + if not client_provided: + await stack.enter_async_context(client) + + def start_get_stream() -> None: + tg.start_soon(transport.handle_get_stream, client, read_stream_writer) + + tg.start_soon( + transport.post_writer, + client, + write_stream_reader, + read_stream_writer, + write_stream, + start_get_stream, + tg, + ) + + try: + yield ( + read_stream, + write_stream, + transport.get_session_id, + ) + finally: + if transport.session_id and terminate_on_close: + await transport.terminate_session(client) + tg.cancel_scope.cancel() + finally: + await read_stream_writer.aclose() + await write_stream.aclose() + + +@asynccontextmanager +@deprecated("Use `streamable_http_client` instead.") +async def streamablehttp_client( + url: str, + headers: dict[str, str] | None = None, + timeout: float | timedelta = 30, + sse_read_timeout: float | timedelta = 60 * 5, + terminate_on_close: bool = True, + httpx_client_factory: McpHttpClientFactory = create_mcp_http_client, + auth: httpx.Auth | None = None, +) -> AsyncGenerator[ + tuple[ + MemoryObjectReceiveStream[SessionMessage | Exception], + MemoryObjectSendStream[SessionMessage], + GetSessionIdCallback, + ], + None, +]: + # Convert timeout parameters + timeout_seconds = timeout.total_seconds() if isinstance(timeout, timedelta) else timeout + sse_read_timeout_seconds = ( + sse_read_timeout.total_seconds() if isinstance(sse_read_timeout, timedelta) else sse_read_timeout + ) + + # Create httpx client using the factory with old-style parameters + client = httpx_client_factory( + headers=headers, + timeout=httpx.Timeout(timeout_seconds, read=sse_read_timeout_seconds), + auth=auth, + ) + + # Manage client lifecycle since we created it + async with client: + async with streamable_http_client( + url, + http_client=client, + terminate_on_close=terminate_on_close, + ) as streams: + yield streams diff --git a/src/mcp/client/websocket.py b/src/mcp/client/websocket.py index 2c2ed38b9e..e8c8d9af87 100644 --- a/src/mcp/client/websocket.py +++ b/src/mcp/client/websocket.py @@ -10,6 +10,7 @@ from websockets.typing import Subprotocol import mcp.types as types +from mcp.shared.message import SessionMessage logger = logging.getLogger(__name__) @@ -18,10 +19,7 @@ async def websocket_client( url: str, ) -> AsyncGenerator[ - tuple[ - MemoryObjectReceiveStream[types.JSONRPCMessage | Exception], - MemoryObjectSendStream[types.JSONRPCMessage], - ], + tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]], None, ]: """ @@ -39,10 +37,10 @@ async def websocket_client( # Create two in-memory streams: # - One for incoming messages (read_stream, written by ws_reader) # - One for outgoing messages (write_stream, read by ws_writer) - read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception] - read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception] - write_stream: MemoryObjectSendStream[types.JSONRPCMessage] - write_stream_reader: MemoryObjectReceiveStream[types.JSONRPCMessage] + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] + read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] + write_stream: MemoryObjectSendStream[SessionMessage] + write_stream_reader: MemoryObjectReceiveStream[SessionMessage] read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) @@ -59,8 +57,9 @@ async def ws_reader(): async for raw_text in ws: try: message = types.JSONRPCMessage.model_validate_json(raw_text) - await read_stream_writer.send(message) - except ValidationError as exc: + session_message = SessionMessage(message) + await read_stream_writer.send(session_message) + except ValidationError as exc: # pragma: no cover # If JSON parse or model validation fails, send the exception await read_stream_writer.send(exc) @@ -70,11 +69,9 @@ async def ws_writer(): sends them to the server. """ async with write_stream_reader: - async for message in write_stream_reader: + async for session_message in write_stream_reader: # Convert to a dict, then to JSON - msg_dict = message.model_dump( - by_alias=True, mode="json", exclude_none=True - ) + msg_dict = session_message.message.model_dump(by_alias=True, mode="json", exclude_none=True) await ws.send(json.dumps(msg_dict)) async with anyio.create_task_group() as tg: diff --git a/src/mcp/os/__init__.py b/src/mcp/os/__init__.py new file mode 100644 index 0000000000..fa5dbc809c --- /dev/null +++ b/src/mcp/os/__init__.py @@ -0,0 +1 @@ +"""Platform-specific utilities for MCP.""" diff --git a/src/mcp/os/posix/__init__.py b/src/mcp/os/posix/__init__.py new file mode 100644 index 0000000000..23aff8bb02 --- /dev/null +++ b/src/mcp/os/posix/__init__.py @@ -0,0 +1 @@ +"""POSIX-specific utilities for MCP.""" diff --git a/src/mcp/os/posix/utilities.py b/src/mcp/os/posix/utilities.py new file mode 100644 index 0000000000..dd1aea363a --- /dev/null +++ b/src/mcp/os/posix/utilities.py @@ -0,0 +1,60 @@ +""" +POSIX-specific functionality for stdio client operations. +""" + +import logging +import os +import signal + +import anyio +from anyio.abc import Process + +logger = logging.getLogger(__name__) + + +async def terminate_posix_process_tree(process: Process, timeout_seconds: float = 2.0) -> None: + """ + Terminate a process and all its children on POSIX systems. + + Uses os.killpg() for atomic process group termination. + + Args: + process: The process to terminate + timeout_seconds: Timeout in seconds before force killing (default: 2.0) + """ + pid = getattr(process, "pid", None) or getattr(getattr(process, "popen", None), "pid", None) + if not pid: + # No PID means there's no process to terminate - it either never started, + # already exited, or we have an invalid process object + return + + try: + pgid = os.getpgid(pid) + os.killpg(pgid, signal.SIGTERM) + + with anyio.move_on_after(timeout_seconds): + while True: + try: + # Check if process group still exists (signal 0 = check only) + os.killpg(pgid, 0) + await anyio.sleep(0.1) + except ProcessLookupError: + return + + try: + os.killpg(pgid, signal.SIGKILL) + except ProcessLookupError: + pass + + except (ProcessLookupError, PermissionError, OSError) as e: + logger.warning(f"Process group termination failed for PID {pid}: {e}, falling back to simple terminate") + try: + process.terminate() + with anyio.fail_after(timeout_seconds): + await process.wait() + except Exception: + logger.warning(f"Process termination failed for PID {pid}, attempting force kill") + try: + process.kill() + except Exception: + logger.exception(f"Failed to kill process {pid}") diff --git a/src/mcp/os/win32/__init__.py b/src/mcp/os/win32/__init__.py new file mode 100644 index 0000000000..f1ebab98df --- /dev/null +++ b/src/mcp/os/win32/__init__.py @@ -0,0 +1 @@ +"""Windows-specific utilities for MCP.""" diff --git a/src/mcp/os/win32/utilities.py b/src/mcp/os/win32/utilities.py new file mode 100644 index 0000000000..962be0229b --- /dev/null +++ b/src/mcp/os/win32/utilities.py @@ -0,0 +1,338 @@ +""" +Windows-specific functionality for stdio client operations. +""" + +import logging +import shutil +import subprocess +import sys +from pathlib import Path +from typing import BinaryIO, TextIO, cast + +import anyio +from anyio import to_thread +from anyio.abc import Process +from anyio.streams.file import FileReadStream, FileWriteStream +from typing_extensions import deprecated + +logger = logging.getLogger("client.stdio.win32") + +# Windows-specific imports for Job Objects +if sys.platform == "win32": + import pywintypes + import win32api + import win32con + import win32job +else: + # Type stubs for non-Windows platforms + win32api = None + win32con = None + win32job = None + pywintypes = None + +JobHandle = int + + +def get_windows_executable_command(command: str) -> str: + """ + Get the correct executable command normalized for Windows. + + On Windows, commands might exist with specific extensions (.exe, .cmd, etc.) + that need to be located for proper execution. + + Args: + command: Base command (e.g., 'uvx', 'npx') + + Returns: + str: Windows-appropriate command path + """ + try: + # First check if command exists in PATH as-is + if command_path := shutil.which(command): + return command_path + + # Check for Windows-specific extensions + for ext in [".cmd", ".bat", ".exe", ".ps1"]: + ext_version = f"{command}{ext}" + if ext_path := shutil.which(ext_version): + return ext_path + + # For regular commands or if we couldn't find special versions + return command + except OSError: + # Handle file system errors during path resolution + # (permissions, broken symlinks, etc.) + return command + + +class FallbackProcess: + """ + A fallback process wrapper for Windows to handle async I/O + when using subprocess.Popen, which provides sync-only FileIO objects. + + This wraps stdin and stdout into async-compatible + streams (FileReadStream, FileWriteStream), + so that MCP clients expecting async streams can work properly. + """ + + def __init__(self, popen_obj: subprocess.Popen[bytes]): + self.popen: subprocess.Popen[bytes] = popen_obj + self.stdin_raw = popen_obj.stdin # type: ignore[assignment] + self.stdout_raw = popen_obj.stdout # type: ignore[assignment] + self.stderr = popen_obj.stderr # type: ignore[assignment] + + self.stdin = FileWriteStream(cast(BinaryIO, self.stdin_raw)) if self.stdin_raw else None + self.stdout = FileReadStream(cast(BinaryIO, self.stdout_raw)) if self.stdout_raw else None + + async def __aenter__(self): + """Support async context manager entry.""" + return self + + async def __aexit__( + self, + exc_type: BaseException | None, + exc_val: BaseException | None, + exc_tb: object | None, + ) -> None: + """Terminate and wait on process exit inside a thread.""" + self.popen.terminate() + await to_thread.run_sync(self.popen.wait) + + # Close the file handles to prevent ResourceWarning + if self.stdin: + await self.stdin.aclose() + if self.stdout: + await self.stdout.aclose() + if self.stdin_raw: + self.stdin_raw.close() + if self.stdout_raw: + self.stdout_raw.close() + if self.stderr: + self.stderr.close() + + async def wait(self): + """Async wait for process completion.""" + return await to_thread.run_sync(self.popen.wait) + + def terminate(self): + """Terminate the subprocess immediately.""" + return self.popen.terminate() + + def kill(self) -> None: + """Kill the subprocess immediately (alias for terminate).""" + self.terminate() + + @property + def pid(self) -> int: + """Return the process ID.""" + return self.popen.pid + + +# ------------------------ +# Updated function +# ------------------------ + + +async def create_windows_process( + command: str, + args: list[str], + env: dict[str, str] | None = None, + errlog: TextIO | None = sys.stderr, + cwd: Path | str | None = None, +) -> Process | FallbackProcess: + """ + Creates a subprocess in a Windows-compatible way with Job Object support. + + Attempt to use anyio's open_process for async subprocess creation. + In some cases this will throw NotImplementedError on Windows, e.g. + when using the SelectorEventLoop which does not support async subprocesses. + In that case, we fall back to using subprocess.Popen. + + The process is automatically added to a Job Object to ensure all child + processes are terminated when the parent is terminated. + + Args: + command (str): The executable to run + args (list[str]): List of command line arguments + env (dict[str, str] | None): Environment variables + errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr) + cwd (Path | str | None): Working directory for the subprocess + + Returns: + Process | FallbackProcess: Async-compatible subprocess with stdin and stdout streams + """ + job = _create_job_object() + process = None + + try: + # First try using anyio with Windows-specific flags to hide console window + process = await anyio.open_process( + [command, *args], + env=env, + # Ensure we don't create console windows for each process + creationflags=subprocess.CREATE_NO_WINDOW # type: ignore + if hasattr(subprocess, "CREATE_NO_WINDOW") + else 0, + stderr=errlog, + cwd=cwd, + ) + except NotImplementedError: + # If Windows doesn't support async subprocess creation, use fallback + process = await _create_windows_fallback_process(command, args, env, errlog, cwd) + except Exception: + # Try again without creation flags + process = await anyio.open_process( + [command, *args], + env=env, + stderr=errlog, + cwd=cwd, + ) + + _maybe_assign_process_to_job(process, job) + return process + + +async def _create_windows_fallback_process( + command: str, + args: list[str], + env: dict[str, str] | None = None, + errlog: TextIO | None = sys.stderr, + cwd: Path | str | None = None, +) -> FallbackProcess: + """ + Create a subprocess using subprocess.Popen as a fallback when anyio fails. + + This function wraps the sync subprocess.Popen in an async-compatible interface. + """ + try: + # Try launching with creationflags to avoid opening a new console window + popen_obj = subprocess.Popen( + [command, *args], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=errlog, + env=env, + cwd=cwd, + bufsize=0, # Unbuffered output + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + except Exception: + # If creationflags failed, fallback without them + popen_obj = subprocess.Popen( + [command, *args], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=errlog, + env=env, + cwd=cwd, + bufsize=0, + ) + return FallbackProcess(popen_obj) + + +def _create_job_object() -> int | None: + """ + Create a Windows Job Object configured to terminate all processes when closed. + """ + if sys.platform != "win32" or not win32job: + return None + + try: + job = win32job.CreateJobObject(None, "") + extended_info = win32job.QueryInformationJobObject(job, win32job.JobObjectExtendedLimitInformation) + + extended_info["BasicLimitInformation"]["LimitFlags"] |= win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + win32job.SetInformationJobObject(job, win32job.JobObjectExtendedLimitInformation, extended_info) + return job + except Exception as e: + logger.warning(f"Failed to create Job Object for process tree management: {e}") + return None + + +def _maybe_assign_process_to_job(process: Process | FallbackProcess, job: JobHandle | None) -> None: + """ + Try to assign a process to a job object. If assignment fails + for any reason, the job handle is closed. + """ + if not job: + return + + if sys.platform != "win32" or not win32api or not win32con or not win32job: + return + + try: + process_handle = win32api.OpenProcess( + win32con.PROCESS_SET_QUOTA | win32con.PROCESS_TERMINATE, False, process.pid + ) + if not process_handle: + raise Exception("Failed to open process handle") + + try: + win32job.AssignProcessToJobObject(job, process_handle) + process._job_object = job + finally: + win32api.CloseHandle(process_handle) + except Exception as e: + logger.warning(f"Failed to assign process {process.pid} to Job Object: {e}") + if win32api: + win32api.CloseHandle(job) + + +async def terminate_windows_process_tree(process: Process | FallbackProcess, timeout_seconds: float = 2.0) -> None: + """ + Terminate a process and all its children on Windows. + + If the process has an associated job object, it will be terminated. + Otherwise, falls back to basic process termination. + + Args: + process: The process to terminate + timeout_seconds: Timeout in seconds before force killing (default: 2.0) + """ + if sys.platform != "win32": + return + + job = getattr(process, "_job_object", None) + if job and win32job: + try: + win32job.TerminateJobObject(job, 1) + except Exception: + # Job might already be terminated + pass + finally: + if win32api: + try: + win32api.CloseHandle(job) + except Exception: + pass + + # Always try to terminate the process itself as well + try: + process.terminate() + except Exception: + pass + + +@deprecated( + "terminate_windows_process is deprecated and will be removed in a future version. " + "Process termination is now handled internally by the stdio_client context manager." +) +async def terminate_windows_process(process: Process | FallbackProcess): + """ + Terminate a Windows process. + + Note: On Windows, terminating a process with process.terminate() doesn't + always guarantee immediate process termination. + So we give it 2s to exit, or we call process.kill() + which sends a SIGKILL equivalent signal. + + Args: + process: The process to terminate + """ + try: + process.terminate() + with anyio.fail_after(2.0): + await process.wait() + except TimeoutError: + # Force kill if it doesn't terminate + process.kill() diff --git a/src/mcp/server/auth/__init__.py b/src/mcp/server/auth/__init__.py new file mode 100644 index 0000000000..6888ffe8d9 --- /dev/null +++ b/src/mcp/server/auth/__init__.py @@ -0,0 +1,3 @@ +""" +MCP OAuth server authorization components. +""" diff --git a/src/mcp/server/auth/errors.py b/src/mcp/server/auth/errors.py new file mode 100644 index 0000000000..117deea83a --- /dev/null +++ b/src/mcp/server/auth/errors.py @@ -0,0 +1,5 @@ +from pydantic import ValidationError + + +def stringify_pydantic_error(validation_error: ValidationError) -> str: + return "\n".join(f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in validation_error.errors()) diff --git a/src/mcp/server/auth/handlers/__init__.py b/src/mcp/server/auth/handlers/__init__.py new file mode 100644 index 0000000000..e99a62de1a --- /dev/null +++ b/src/mcp/server/auth/handlers/__init__.py @@ -0,0 +1,3 @@ +""" +Request handlers for MCP authorization endpoints. +""" diff --git a/src/mcp/server/auth/handlers/authorize.py b/src/mcp/server/auth/handlers/authorize.py new file mode 100644 index 0000000000..3570d28c2a --- /dev/null +++ b/src/mcp/server/auth/handlers/authorize.py @@ -0,0 +1,224 @@ +import logging +from dataclasses import dataclass +from typing import Any, Literal + +from pydantic import AnyUrl, BaseModel, Field, RootModel, ValidationError +from starlette.datastructures import FormData, QueryParams +from starlette.requests import Request +from starlette.responses import RedirectResponse, Response + +from mcp.server.auth.errors import stringify_pydantic_error +from mcp.server.auth.json_response import PydanticJSONResponse +from mcp.server.auth.provider import ( + AuthorizationErrorCode, + AuthorizationParams, + AuthorizeError, + OAuthAuthorizationServerProvider, + construct_redirect_uri, +) +from mcp.shared.auth import InvalidRedirectUriError, InvalidScopeError + +logger = logging.getLogger(__name__) + + +class AuthorizationRequest(BaseModel): + # See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 + client_id: str = Field(..., description="The client ID") + redirect_uri: AnyUrl | None = Field(None, description="URL to redirect to after authorization") + + # see OAuthClientMetadata; we only support `code` + response_type: Literal["code"] = Field(..., description="Must be 'code' for authorization code flow") + code_challenge: str = Field(..., description="PKCE code challenge") + code_challenge_method: Literal["S256"] = Field("S256", description="PKCE code challenge method, must be S256") + state: str | None = Field(None, description="Optional state parameter") + scope: str | None = Field( + None, + description="Optional scope; if specified, should be a space-separated list of scope strings", + ) + resource: str | None = Field( + None, + description="RFC 8707 resource indicator - the MCP server this token will be used with", + ) + + +class AuthorizationErrorResponse(BaseModel): + error: AuthorizationErrorCode + error_description: str | None + error_uri: AnyUrl | None = None + # must be set if provided in the request + state: str | None = None + + +def best_effort_extract_string(key: str, params: None | FormData | QueryParams) -> str | None: + if params is None: # pragma: no cover + return None + value = params.get(key) + if isinstance(value, str): + return value + return None + + +class AnyUrlModel(RootModel[AnyUrl]): + root: AnyUrl + + +@dataclass +class AuthorizationHandler: + provider: OAuthAuthorizationServerProvider[Any, Any, Any] + + async def handle(self, request: Request) -> Response: + # implements authorization requests for grant_type=code; + # see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 + + state = None + redirect_uri = None + client = None + params = None + + async def error_response( + error: AuthorizationErrorCode, + error_description: str | None, + attempt_load_client: bool = True, + ): + # Error responses take two different formats: + # 1. The request has a valid client ID & redirect_uri: we issue a redirect + # back to the redirect_uri with the error response fields as query + # parameters. This allows the client to be notified of the error. + # 2. Otherwise, we return an error response directly to the end user; + # we choose to do so in JSON, but this is left undefined in the + # specification. + # See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 + # + # This logic is a bit awkward to handle, because the error might be thrown + # very early in request validation, before we've done the usual Pydantic + # validation, loaded the client, etc. To handle this, error_response() + # contains fallback logic which attempts to load the parameters directly + # from the request. + + nonlocal client, redirect_uri, state + if client is None and attempt_load_client: + # make last-ditch attempt to load the client + client_id = best_effort_extract_string("client_id", params) + client = await self.provider.get_client(client_id) if client_id else None + if redirect_uri is None and client: + # make last-ditch effort to load the redirect uri + try: + if params is not None and "redirect_uri" not in params: + raw_redirect_uri = None + else: + raw_redirect_uri = AnyUrlModel.model_validate( + best_effort_extract_string("redirect_uri", params) + ).root + redirect_uri = client.validate_redirect_uri(raw_redirect_uri) + except (ValidationError, InvalidRedirectUriError): + # if the redirect URI is invalid, ignore it & just return the + # initial error + pass + + # the error response MUST contain the state specified by the client, if any + if state is None: # pragma: no cover + # make last-ditch effort to load state + state = best_effort_extract_string("state", params) + + error_resp = AuthorizationErrorResponse( + error=error, + error_description=error_description, + state=state, + ) + + if redirect_uri and client: + return RedirectResponse( + url=construct_redirect_uri(str(redirect_uri), **error_resp.model_dump(exclude_none=True)), + status_code=302, + headers={"Cache-Control": "no-store"}, + ) + else: + return PydanticJSONResponse( + status_code=400, + content=error_resp, + headers={"Cache-Control": "no-store"}, + ) + + try: + # Parse request parameters + if request.method == "GET": + # Convert query_params to dict for pydantic validation + params = request.query_params + else: + # Parse form data for POST requests + params = await request.form() + + # Save state if it exists, even before validation + state = best_effort_extract_string("state", params) + + try: + auth_request = AuthorizationRequest.model_validate(params) + state = auth_request.state # Update with validated state + except ValidationError as validation_error: + error: AuthorizationErrorCode = "invalid_request" + for e in validation_error.errors(): + if e["loc"] == ("response_type",) and e["type"] == "literal_error": + error = "unsupported_response_type" + break + return await error_response(error, stringify_pydantic_error(validation_error)) + + # Get client information + client = await self.provider.get_client( + auth_request.client_id, + ) + if not client: + # For client_id validation errors, return direct error (no redirect) + return await error_response( + error="invalid_request", + error_description=f"Client ID '{auth_request.client_id}' not found", + attempt_load_client=False, + ) + + # Validate redirect_uri against client's registered URIs + try: + redirect_uri = client.validate_redirect_uri(auth_request.redirect_uri) + except InvalidRedirectUriError as validation_error: + # For redirect_uri validation errors, return direct error (no redirect) + return await error_response( + error="invalid_request", + error_description=validation_error.message, + ) + + # Validate scope - for scope errors, we can redirect + try: + scopes = client.validate_scope(auth_request.scope) + except InvalidScopeError as validation_error: + # For scope errors, redirect with error parameters + return await error_response( + error="invalid_scope", + error_description=validation_error.message, + ) + + # Setup authorization parameters + auth_params = AuthorizationParams( + state=state, + scopes=scopes, + code_challenge=auth_request.code_challenge, + redirect_uri=redirect_uri, + redirect_uri_provided_explicitly=auth_request.redirect_uri is not None, + resource=auth_request.resource, # RFC 8707 + ) + + try: + # Let the provider pick the next URI to redirect to + return RedirectResponse( + url=await self.provider.authorize( + client, + auth_params, + ), + status_code=302, + headers={"Cache-Control": "no-store"}, + ) + except AuthorizeError as e: + # Handle authorization errors as defined in RFC 6749 Section 4.1.2.1 + return await error_response(error=e.error, error_description=e.error_description) + + except Exception as validation_error: # pragma: no cover + # Catch-all for unexpected errors + logger.exception("Unexpected error in authorization_handler", exc_info=validation_error) + return await error_response(error="server_error", error_description="An unexpected error occurred") diff --git a/src/mcp/server/auth/handlers/metadata.py b/src/mcp/server/auth/handlers/metadata.py new file mode 100644 index 0000000000..f126442150 --- /dev/null +++ b/src/mcp/server/auth/handlers/metadata.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass + +from starlette.requests import Request +from starlette.responses import Response + +from mcp.server.auth.json_response import PydanticJSONResponse +from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata + + +@dataclass +class MetadataHandler: + metadata: OAuthMetadata + + async def handle(self, request: Request) -> Response: + return PydanticJSONResponse( + content=self.metadata, + headers={"Cache-Control": "public, max-age=3600"}, # Cache for 1 hour + ) + + +@dataclass +class ProtectedResourceMetadataHandler: + metadata: ProtectedResourceMetadata + + async def handle(self, request: Request) -> Response: + return PydanticJSONResponse( + content=self.metadata, + headers={"Cache-Control": "public, max-age=3600"}, # Cache for 1 hour + ) diff --git a/src/mcp/server/auth/handlers/register.py b/src/mcp/server/auth/handlers/register.py new file mode 100644 index 0000000000..c65473d1fc --- /dev/null +++ b/src/mcp/server/auth/handlers/register.py @@ -0,0 +1,136 @@ +import secrets +import time +from dataclasses import dataclass +from typing import Any +from uuid import uuid4 + +from pydantic import BaseModel, RootModel, ValidationError +from starlette.requests import Request +from starlette.responses import Response + +from mcp.server.auth.errors import stringify_pydantic_error +from mcp.server.auth.json_response import PydanticJSONResponse +from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode +from mcp.server.auth.settings import ClientRegistrationOptions +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata + + +class RegistrationRequest(RootModel[OAuthClientMetadata]): + # this wrapper is a no-op; it's just to separate out the types exposed to the + # provider from what we use in the HTTP handler + root: OAuthClientMetadata + + +class RegistrationErrorResponse(BaseModel): + error: RegistrationErrorCode + error_description: str | None + + +@dataclass +class RegistrationHandler: + provider: OAuthAuthorizationServerProvider[Any, Any, Any] + options: ClientRegistrationOptions + + async def handle(self, request: Request) -> Response: + # Implements dynamic client registration as defined in https://datatracker.ietf.org/doc/html/rfc7591#section-3.1 + try: + # Parse request body as JSON + body = await request.json() + client_metadata = OAuthClientMetadata.model_validate(body) + + # Scope validation is handled below + except ValidationError as validation_error: + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_client_metadata", + error_description=stringify_pydantic_error(validation_error), + ), + status_code=400, + ) + + client_id = str(uuid4()) + + # If auth method is None, default to client_secret_post + if client_metadata.token_endpoint_auth_method is None: + client_metadata.token_endpoint_auth_method = "client_secret_post" + + client_secret = None + if client_metadata.token_endpoint_auth_method != "none": # pragma: no branch + # cryptographically secure random 32-byte hex string + client_secret = secrets.token_hex(32) + + if client_metadata.scope is None and self.options.default_scopes is not None: + client_metadata.scope = " ".join(self.options.default_scopes) + elif client_metadata.scope is not None and self.options.valid_scopes is not None: + requested_scopes = set(client_metadata.scope.split()) + valid_scopes = set(self.options.valid_scopes) + if not requested_scopes.issubset(valid_scopes): # pragma: no branch + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_client_metadata", + error_description="Requested scopes are not valid: " + f"{', '.join(requested_scopes - valid_scopes)}", + ), + status_code=400, + ) + if not {"authorization_code", "refresh_token"}.issubset(set(client_metadata.grant_types)): + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_client_metadata", + error_description="grant_types must be authorization_code and refresh_token", + ), + status_code=400, + ) + + # The MCP spec requires servers to use the authorization `code` flow + # with PKCE + if "code" not in client_metadata.response_types: + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_client_metadata", + error_description="response_types must include 'code' for authorization_code grant", + ), + status_code=400, + ) + + client_id_issued_at = int(time.time()) + client_secret_expires_at = ( + client_id_issued_at + self.options.client_secret_expiry_seconds + if self.options.client_secret_expiry_seconds is not None + else None + ) + + client_info = OAuthClientInformationFull( + client_id=client_id, + client_id_issued_at=client_id_issued_at, + client_secret=client_secret, + client_secret_expires_at=client_secret_expires_at, + # passthrough information from the client request + redirect_uris=client_metadata.redirect_uris, + token_endpoint_auth_method=client_metadata.token_endpoint_auth_method, + grant_types=client_metadata.grant_types, + response_types=client_metadata.response_types, + client_name=client_metadata.client_name, + client_uri=client_metadata.client_uri, + logo_uri=client_metadata.logo_uri, + scope=client_metadata.scope, + contacts=client_metadata.contacts, + tos_uri=client_metadata.tos_uri, + policy_uri=client_metadata.policy_uri, + jwks_uri=client_metadata.jwks_uri, + jwks=client_metadata.jwks, + software_id=client_metadata.software_id, + software_version=client_metadata.software_version, + ) + try: + # Register client + await self.provider.register_client(client_info) + + # Return client information + return PydanticJSONResponse(content=client_info, status_code=201) + except RegistrationError as e: + # Handle registration errors as defined in RFC 7591 Section 3.2.2 + return PydanticJSONResponse( + content=RegistrationErrorResponse(error=e.error, error_description=e.error_description), + status_code=400, + ) diff --git a/src/mcp/server/auth/handlers/revoke.py b/src/mcp/server/auth/handlers/revoke.py new file mode 100644 index 0000000000..fa8cfc99d0 --- /dev/null +++ b/src/mcp/server/auth/handlers/revoke.py @@ -0,0 +1,91 @@ +from dataclasses import dataclass +from functools import partial +from typing import Any, Literal + +from pydantic import BaseModel, ValidationError +from starlette.requests import Request +from starlette.responses import Response + +from mcp.server.auth.errors import ( + stringify_pydantic_error, +) +from mcp.server.auth.json_response import PydanticJSONResponse +from mcp.server.auth.middleware.client_auth import AuthenticationError, ClientAuthenticator +from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider, RefreshToken + + +class RevocationRequest(BaseModel): + """ + # See https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 + """ + + token: str + token_type_hint: Literal["access_token", "refresh_token"] | None = None + client_id: str + client_secret: str | None + + +class RevocationErrorResponse(BaseModel): + error: Literal["invalid_request", "unauthorized_client"] + error_description: str | None = None + + +@dataclass +class RevocationHandler: + provider: OAuthAuthorizationServerProvider[Any, Any, Any] + client_authenticator: ClientAuthenticator + + async def handle(self, request: Request) -> Response: + """ + Handler for the OAuth 2.0 Token Revocation endpoint. + """ + try: + client = await self.client_authenticator.authenticate_request(request) + except AuthenticationError as e: # pragma: no cover + return PydanticJSONResponse( + status_code=401, + content=RevocationErrorResponse( + error="unauthorized_client", + error_description=e.message, + ), + ) + + try: + form_data = await request.form() + revocation_request = RevocationRequest.model_validate(dict(form_data)) + except ValidationError as e: + return PydanticJSONResponse( + status_code=400, + content=RevocationErrorResponse( + error="invalid_request", + error_description=stringify_pydantic_error(e), + ), + ) + + loaders = [ + self.provider.load_access_token, + partial(self.provider.load_refresh_token, client), + ] + if revocation_request.token_type_hint == "refresh_token": # pragma: no cover + loaders = reversed(loaders) + + token: None | AccessToken | RefreshToken = None + for loader in loaders: + token = await loader(revocation_request.token) + if token is not None: + break + + # if token is not found, just return HTTP 200 per the RFC + if token and token.client_id == client.client_id: + # Revoke token; provider is not meant to be able to do validation + # at this point that would result in an error + await self.provider.revoke_token(token) + + # Return successful empty response + return Response( + status_code=200, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, + ) diff --git a/src/mcp/server/auth/handlers/token.py b/src/mcp/server/auth/handlers/token.py new file mode 100644 index 0000000000..7e8294ce6e --- /dev/null +++ b/src/mcp/server/auth/handlers/token.py @@ -0,0 +1,241 @@ +import base64 +import hashlib +import time +from dataclasses import dataclass +from typing import Annotated, Any, Literal + +from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, RootModel, ValidationError +from starlette.requests import Request + +from mcp.server.auth.errors import stringify_pydantic_error +from mcp.server.auth.json_response import PydanticJSONResponse +from mcp.server.auth.middleware.client_auth import AuthenticationError, ClientAuthenticator +from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenError, TokenErrorCode +from mcp.shared.auth import OAuthToken + + +class AuthorizationCodeRequest(BaseModel): + # See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + grant_type: Literal["authorization_code"] + code: str = Field(..., description="The authorization code") + redirect_uri: AnyUrl | None = Field(None, description="Must be the same as redirect URI provided in /authorize") + client_id: str + # we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 + client_secret: str | None = None + # See https://datatracker.ietf.org/doc/html/rfc7636#section-4.5 + code_verifier: str = Field(..., description="PKCE code verifier") + # RFC 8707 resource indicator + resource: str | None = Field(None, description="Resource indicator for the token") + + +class RefreshTokenRequest(BaseModel): + # See https://datatracker.ietf.org/doc/html/rfc6749#section-6 + grant_type: Literal["refresh_token"] + refresh_token: str = Field(..., description="The refresh token") + scope: str | None = Field(None, description="Optional scope parameter") + client_id: str + # we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 + client_secret: str | None = None + # RFC 8707 resource indicator + resource: str | None = Field(None, description="Resource indicator for the token") + + +class TokenRequest( + RootModel[ + Annotated[ + AuthorizationCodeRequest | RefreshTokenRequest, + Field(discriminator="grant_type"), + ] + ] +): + root: Annotated[ + AuthorizationCodeRequest | RefreshTokenRequest, + Field(discriminator="grant_type"), + ] + + +class TokenErrorResponse(BaseModel): + """ + See https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + """ + + error: TokenErrorCode + error_description: str | None = None + error_uri: AnyHttpUrl | None = None + + +class TokenSuccessResponse(RootModel[OAuthToken]): + # this is just a wrapper over OAuthToken; the only reason we do this + # is to have some separation between the HTTP response type, and the + # type returned by the provider + root: OAuthToken + + +@dataclass +class TokenHandler: + provider: OAuthAuthorizationServerProvider[Any, Any, Any] + client_authenticator: ClientAuthenticator + + def response(self, obj: TokenSuccessResponse | TokenErrorResponse): + status_code = 200 + if isinstance(obj, TokenErrorResponse): + status_code = 400 + + return PydanticJSONResponse( + content=obj, + status_code=status_code, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, + ) + + async def handle(self, request: Request): + try: + client_info = await self.client_authenticator.authenticate_request(request) + except AuthenticationError as e: + # Authentication failures should return 401 + return PydanticJSONResponse( + content=TokenErrorResponse( + error="invalid_client", + error_description=e.message, + ), + status_code=401, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, + ) + + try: + form_data = await request.form() + token_request = TokenRequest.model_validate(dict(form_data)).root + except ValidationError as validation_error: # pragma: no cover + return self.response( + TokenErrorResponse( + error="invalid_request", + error_description=stringify_pydantic_error(validation_error), + ) + ) + + if token_request.grant_type not in client_info.grant_types: # pragma: no cover + return self.response( + TokenErrorResponse( + error="unsupported_grant_type", + error_description=(f"Unsupported grant type (supported grant types are {client_info.grant_types})"), + ) + ) + + tokens: OAuthToken + + match token_request: + case AuthorizationCodeRequest(): + auth_code = await self.provider.load_authorization_code(client_info, token_request.code) + if auth_code is None or auth_code.client_id != token_request.client_id: + # if code belongs to different client, pretend it doesn't exist + return self.response( + TokenErrorResponse( + error="invalid_grant", + error_description="authorization code does not exist", + ) + ) + + # make auth codes expire after a deadline + # see https://datatracker.ietf.org/doc/html/rfc6749#section-10.5 + if auth_code.expires_at < time.time(): + return self.response( + TokenErrorResponse( + error="invalid_grant", + error_description="authorization code has expired", + ) + ) + + # verify redirect_uri doesn't change between /authorize and /tokens + # see https://datatracker.ietf.org/doc/html/rfc6749#section-10.6 + if auth_code.redirect_uri_provided_explicitly: + authorize_request_redirect_uri = auth_code.redirect_uri + else: # pragma: no cover + authorize_request_redirect_uri = None + + # Convert both sides to strings for comparison to handle AnyUrl vs string issues + token_redirect_str = str(token_request.redirect_uri) if token_request.redirect_uri is not None else None + auth_redirect_str = ( + str(authorize_request_redirect_uri) if authorize_request_redirect_uri is not None else None + ) + + if token_redirect_str != auth_redirect_str: + return self.response( + TokenErrorResponse( + error="invalid_request", + error_description=("redirect_uri did not match the one used when creating auth code"), + ) + ) + + # Verify PKCE code verifier + sha256 = hashlib.sha256(token_request.code_verifier.encode()).digest() + hashed_code_verifier = base64.urlsafe_b64encode(sha256).decode().rstrip("=") + + if hashed_code_verifier != auth_code.code_challenge: + # see https://datatracker.ietf.org/doc/html/rfc7636#section-4.6 + return self.response( + TokenErrorResponse( + error="invalid_grant", + error_description="incorrect code_verifier", + ) + ) + + try: + # Exchange authorization code for tokens + tokens = await self.provider.exchange_authorization_code(client_info, auth_code) + except TokenError as e: + return self.response( + TokenErrorResponse( + error=e.error, + error_description=e.error_description, + ) + ) + + case RefreshTokenRequest(): # pragma: no cover + refresh_token = await self.provider.load_refresh_token(client_info, token_request.refresh_token) + if refresh_token is None or refresh_token.client_id != token_request.client_id: + # if token belongs to different client, pretend it doesn't exist + return self.response( + TokenErrorResponse( + error="invalid_grant", + error_description="refresh token does not exist", + ) + ) + + if refresh_token.expires_at and refresh_token.expires_at < time.time(): + # if the refresh token has expired, pretend it doesn't exist + return self.response( + TokenErrorResponse( + error="invalid_grant", + error_description="refresh token has expired", + ) + ) + + # Parse scopes if provided + scopes = token_request.scope.split(" ") if token_request.scope else refresh_token.scopes + + for scope in scopes: + if scope not in refresh_token.scopes: + return self.response( + TokenErrorResponse( + error="invalid_scope", + error_description=(f"cannot request scope `{scope}` not provided by refresh token"), + ) + ) + + try: + # Exchange refresh token for new tokens + tokens = await self.provider.exchange_refresh_token(client_info, refresh_token, scopes) + except TokenError as e: + return self.response( + TokenErrorResponse( + error=e.error, + error_description=e.error_description, + ) + ) + + return self.response(TokenSuccessResponse(root=tokens)) diff --git a/src/mcp/server/auth/json_response.py b/src/mcp/server/auth/json_response.py new file mode 100644 index 0000000000..bd95bd693b --- /dev/null +++ b/src/mcp/server/auth/json_response.py @@ -0,0 +1,10 @@ +from typing import Any + +from starlette.responses import JSONResponse + + +class PydanticJSONResponse(JSONResponse): + # use pydantic json serialization instead of the stock `json.dumps`, + # so that we can handle serializing pydantic models like AnyHttpUrl + def render(self, content: Any) -> bytes: + return content.model_dump_json(exclude_none=True).encode("utf-8") diff --git a/src/mcp/server/auth/middleware/__init__.py b/src/mcp/server/auth/middleware/__init__.py new file mode 100644 index 0000000000..ba3ff63c34 --- /dev/null +++ b/src/mcp/server/auth/middleware/__init__.py @@ -0,0 +1,3 @@ +""" +Middleware for MCP authorization. +""" diff --git a/src/mcp/server/auth/middleware/auth_context.py b/src/mcp/server/auth/middleware/auth_context.py new file mode 100644 index 0000000000..e2116c3bfd --- /dev/null +++ b/src/mcp/server/auth/middleware/auth_context.py @@ -0,0 +1,48 @@ +import contextvars + +from starlette.types import ASGIApp, Receive, Scope, Send + +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.auth.provider import AccessToken + +# Create a contextvar to store the authenticated user +# The default is None, indicating no authenticated user is present +auth_context_var = contextvars.ContextVar[AuthenticatedUser | None]("auth_context", default=None) + + +def get_access_token() -> AccessToken | None: + """ + Get the access token from the current context. + + Returns: + The access token if an authenticated user is available, None otherwise. + """ + auth_user = auth_context_var.get() + return auth_user.access_token if auth_user else None + + +class AuthContextMiddleware: + """ + Middleware that extracts the authenticated user from the request + and sets it in a contextvar for easy access throughout the request lifecycle. + + This middleware should be added after the AuthenticationMiddleware in the + middleware stack to ensure that the user is properly authenticated before + being stored in the context. + """ + + def __init__(self, app: ASGIApp): + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send): + user = scope.get("user") + if isinstance(user, AuthenticatedUser): + # Set the authenticated user in the contextvar + token = auth_context_var.set(user) + try: + await self.app(scope, receive, send) + finally: + auth_context_var.reset(token) + else: + # No authenticated user, just process the request + await self.app(scope, receive, send) diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py new file mode 100644 index 0000000000..64c9b8841f --- /dev/null +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -0,0 +1,128 @@ +import json +import time +from typing import Any + +from pydantic import AnyHttpUrl +from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser +from starlette.requests import HTTPConnection +from starlette.types import Receive, Scope, Send + +from mcp.server.auth.provider import AccessToken, TokenVerifier + + +class AuthenticatedUser(SimpleUser): + """User with authentication info.""" + + def __init__(self, auth_info: AccessToken): + super().__init__(auth_info.client_id) + self.access_token = auth_info + self.scopes = auth_info.scopes + + +class BearerAuthBackend(AuthenticationBackend): + """ + Authentication backend that validates Bearer tokens using a TokenVerifier. + """ + + def __init__(self, token_verifier: TokenVerifier): + self.token_verifier = token_verifier + + async def authenticate(self, conn: HTTPConnection): + auth_header = next( + (conn.headers.get(key) for key in conn.headers if key.lower() == "authorization"), + None, + ) + if not auth_header or not auth_header.lower().startswith("bearer "): + return None + + token = auth_header[7:] # Remove "Bearer " prefix + + # Validate the token with the verifier + auth_info = await self.token_verifier.verify_token(token) + + if not auth_info: + return None + + if auth_info.expires_at and auth_info.expires_at < int(time.time()): + return None + + return AuthCredentials(auth_info.scopes), AuthenticatedUser(auth_info) + + +class RequireAuthMiddleware: + """ + Middleware that requires a valid Bearer token in the Authorization header. + + This will validate the token with the auth provider and store the resulting + auth info in the request state. + """ + + def __init__( + self, + app: Any, + required_scopes: list[str], + resource_metadata_url: AnyHttpUrl | None = None, + ): + """ + Initialize the middleware. + + Args: + app: ASGI application + required_scopes: List of scopes that the token must have + resource_metadata_url: Optional protected resource metadata URL for WWW-Authenticate header + """ + self.app = app + self.required_scopes = required_scopes + self.resource_metadata_url = resource_metadata_url + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + auth_user = scope.get("user") + if not isinstance(auth_user, AuthenticatedUser): + await self._send_auth_error( + send, status_code=401, error="invalid_token", description="Authentication required" + ) + return + + auth_credentials = scope.get("auth") + + for required_scope in self.required_scopes: + # auth_credentials should always be provided; this is just paranoia + if auth_credentials is None or required_scope not in auth_credentials.scopes: + await self._send_auth_error( + send, status_code=403, error="insufficient_scope", description=f"Required scope: {required_scope}" + ) + return + + await self.app(scope, receive, send) + + async def _send_auth_error(self, send: Send, status_code: int, error: str, description: str) -> None: + """Send an authentication error response with WWW-Authenticate header.""" + # Build WWW-Authenticate header value + www_auth_parts = [f'error="{error}"', f'error_description="{description}"'] + if self.resource_metadata_url: # pragma: no cover + www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"') + + www_authenticate = f"Bearer {', '.join(www_auth_parts)}" + + # Send response + body = {"error": error, "error_description": description} + body_bytes = json.dumps(body).encode() + + await send( + { + "type": "http.response.start", + "status": status_code, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body_bytes)).encode()), + (b"www-authenticate", www_authenticate.encode()), + ], + } + ) + + await send( + { + "type": "http.response.body", + "body": body_bytes, + } + ) diff --git a/src/mcp/server/auth/middleware/client_auth.py b/src/mcp/server/auth/middleware/client_auth.py new file mode 100644 index 0000000000..6126c6e4f9 --- /dev/null +++ b/src/mcp/server/auth/middleware/client_auth.py @@ -0,0 +1,115 @@ +import base64 +import binascii +import hmac +import time +from typing import Any +from urllib.parse import unquote + +from starlette.requests import Request + +from mcp.server.auth.provider import OAuthAuthorizationServerProvider +from mcp.shared.auth import OAuthClientInformationFull + + +class AuthenticationError(Exception): + def __init__(self, message: str): + self.message = message # pragma: no cover + + +class ClientAuthenticator: + """ + ClientAuthenticator is a callable which validates requests from a client + application, used to verify /token calls. + If, during registration, the client requested to be issued a secret, the + authenticator asserts that /token calls must be authenticated with + that same token. + NOTE: clients can opt for no authentication during registration, in which case this + logic is skipped. + """ + + def __init__(self, provider: OAuthAuthorizationServerProvider[Any, Any, Any]): + """ + Initialize the dependency. + + Args: + provider: Provider to look up client information + """ + self.provider = provider + + async def authenticate_request(self, request: Request) -> OAuthClientInformationFull: + """ + Authenticate a client from an HTTP request. + + Extracts client credentials from the appropriate location based on the + client's registered authentication method and validates them. + + Args: + request: The HTTP request containing client credentials + + Returns: + The authenticated client information + + Raises: + AuthenticationError: If authentication fails + """ + form_data = await request.form() + client_id = form_data.get("client_id") + if not client_id: + raise AuthenticationError("Missing client_id") + + client = await self.provider.get_client(str(client_id)) + if not client: + raise AuthenticationError("Invalid client_id") # pragma: no cover + + request_client_secret: str | None = None + auth_header = request.headers.get("Authorization", "") + + if client.token_endpoint_auth_method == "client_secret_basic": + if not auth_header.startswith("Basic "): + raise AuthenticationError("Missing or invalid Basic authentication in Authorization header") + + try: + encoded_credentials = auth_header[6:] # Remove "Basic " prefix + decoded = base64.b64decode(encoded_credentials).decode("utf-8") + if ":" not in decoded: + raise ValueError("Invalid Basic auth format") + basic_client_id, request_client_secret = decoded.split(":", 1) + + # URL-decode both parts per RFC 6749 Section 2.3.1 + basic_client_id = unquote(basic_client_id) + request_client_secret = unquote(request_client_secret) + + if basic_client_id != client_id: + raise AuthenticationError("Client ID mismatch in Basic auth") + except (ValueError, UnicodeDecodeError, binascii.Error): + raise AuthenticationError("Invalid Basic authentication header") + + elif client.token_endpoint_auth_method == "client_secret_post": + raw_form_data = form_data.get("client_secret") + # form_data.get() can return a UploadFile or None, so we need to check if it's a string + if isinstance(raw_form_data, str): + request_client_secret = str(raw_form_data) + + elif client.token_endpoint_auth_method == "none": + request_client_secret = None + else: + raise AuthenticationError( # pragma: no cover + f"Unsupported auth method: {client.token_endpoint_auth_method}" + ) + + # If client from the store expects a secret, validate that the request provides + # that secret + if client.client_secret: # pragma: no branch + if not request_client_secret: + raise AuthenticationError("Client secret is required") # pragma: no cover + + # hmac.compare_digest requires that both arguments are either bytes or a `str` containing + # only ASCII characters. Since we do not control `request_client_secret`, we encode both + # arguments to bytes. + if not hmac.compare_digest(client.client_secret.encode(), request_client_secret.encode()): + raise AuthenticationError("Invalid client_secret") # pragma: no cover + + if client.client_secret_expires_at and client.client_secret_expires_at < int(time.time()): + raise AuthenticationError("Client secret has expired") # pragma: no cover + + return client diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py new file mode 100644 index 0000000000..96296c148e --- /dev/null +++ b/src/mcp/server/auth/provider.py @@ -0,0 +1,301 @@ +from dataclasses import dataclass +from typing import Generic, Literal, Protocol, TypeVar +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + +from pydantic import AnyUrl, BaseModel + +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +class AuthorizationParams(BaseModel): + state: str | None + scopes: list[str] | None + code_challenge: str + redirect_uri: AnyUrl + redirect_uri_provided_explicitly: bool + resource: str | None = None # RFC 8707 resource indicator + + +class AuthorizationCode(BaseModel): + code: str + scopes: list[str] + expires_at: float + client_id: str + code_challenge: str + redirect_uri: AnyUrl + redirect_uri_provided_explicitly: bool + resource: str | None = None # RFC 8707 resource indicator + + +class RefreshToken(BaseModel): + token: str + client_id: str + scopes: list[str] + expires_at: int | None = None + + +class AccessToken(BaseModel): + token: str + client_id: str + scopes: list[str] + expires_at: int | None = None + resource: str | None = None # RFC 8707 resource indicator + + +RegistrationErrorCode = Literal[ + "invalid_redirect_uri", + "invalid_client_metadata", + "invalid_software_statement", + "unapproved_software_statement", +] + + +@dataclass(frozen=True) +class RegistrationError(Exception): + error: RegistrationErrorCode + error_description: str | None = None + + +AuthorizationErrorCode = Literal[ + "invalid_request", + "unauthorized_client", + "access_denied", + "unsupported_response_type", + "invalid_scope", + "server_error", + "temporarily_unavailable", +] + + +@dataclass(frozen=True) +class AuthorizeError(Exception): + error: AuthorizationErrorCode + error_description: str | None = None + + +TokenErrorCode = Literal[ + "invalid_request", + "invalid_client", + "invalid_grant", + "unauthorized_client", + "unsupported_grant_type", + "invalid_scope", +] + + +@dataclass(frozen=True) +class TokenError(Exception): + error: TokenErrorCode + error_description: str | None = None + + +class TokenVerifier(Protocol): + """Protocol for verifying bearer tokens.""" + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify a bearer token and return access info if valid.""" + + +# NOTE: FastMCP doesn't render any of these types in the user response, so it's +# OK to add fields to subclasses which should not be exposed externally. +AuthorizationCodeT = TypeVar("AuthorizationCodeT", bound=AuthorizationCode) +RefreshTokenT = TypeVar("RefreshTokenT", bound=RefreshToken) +AccessTokenT = TypeVar("AccessTokenT", bound=AccessToken) + + +class OAuthAuthorizationServerProvider(Protocol, Generic[AuthorizationCodeT, RefreshTokenT, AccessTokenT]): + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + """ + Retrieves client information by client ID. + + Implementors MAY raise NotImplementedError if dynamic client registration is + disabled in ClientRegistrationOptions. + + Args: + client_id: The ID of the client to retrieve. + + Returns: + The client information, or None if the client does not exist. + """ + + async def register_client(self, client_info: OAuthClientInformationFull) -> None: + """ + Saves client information as part of registering it. + + Implementors MAY raise NotImplementedError if dynamic client registration is + disabled in ClientRegistrationOptions. + + Args: + client_info: The client metadata to register. + + Raises: + RegistrationError: If the client metadata is invalid. + """ + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + """ + Called as part of the /authorize endpoint, and returns a URL that the client + will be redirected to. + Many MCP implementations will redirect to a third-party provider to perform + a second OAuth exchange with that provider. In this sort of setup, the client + has an OAuth connection with the MCP server, and the MCP server has an OAuth + connection with the 3rd-party provider. At the end of this flow, the client + should be redirected to the redirect_uri from params.redirect_uri. + + +--------+ +------------+ +-------------------+ + | | | | | | + | Client | --> | MCP Server | --> | 3rd Party OAuth | + | | | | | Server | + +--------+ +------------+ +-------------------+ + | ^ | + +------------+ | | | + | | | | Redirect | + |redirect_uri|<-----+ +------------------+ + | | + +------------+ + + Implementations will need to define another handler on the MCP server return + flow to perform the second redirect, and generate and store an authorization + code as part of completing the OAuth authorization step. + + Implementations SHOULD generate an authorization code with at least 160 bits of + entropy, + and MUST generate an authorization code with at least 128 bits of entropy. + See https://datatracker.ietf.org/doc/html/rfc6749#section-10.10. + + Args: + client: The client requesting authorization. + params: The parameters of the authorization request. + + Returns: + A URL to redirect the client to for authorization. + + Raises: + AuthorizeError: If the authorization request is invalid. + """ + ... + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> AuthorizationCodeT | None: + """ + Loads an AuthorizationCode by its code. + + Args: + client: The client that requested the authorization code. + authorization_code: The authorization code to get the challenge for. + + Returns: + The AuthorizationCode, or None if not found + """ + ... + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCodeT + ) -> OAuthToken: + """ + Exchanges an authorization code for an access token and refresh token. + + Args: + client: The client exchanging the authorization code. + authorization_code: The authorization code to exchange. + + Returns: + The OAuth token, containing access and refresh tokens. + + Raises: + TokenError: If the request is invalid + """ + ... + + async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshTokenT | None: + """ + Loads a RefreshToken by its token string. + + Args: + client: The client that is requesting to load the refresh token. + refresh_token: The refresh token string to load. + + Returns: + The RefreshToken object if found, or None if not found. + """ + ... + + async def exchange_refresh_token( + self, + client: OAuthClientInformationFull, + refresh_token: RefreshTokenT, + scopes: list[str], + ) -> OAuthToken: + """ + Exchanges a refresh token for an access token and refresh token. + + Implementations SHOULD rotate both the access token and refresh token. + + Args: + client: The client exchanging the refresh token. + refresh_token: The refresh token to exchange. + scopes: Optional scopes to request with the new access token. + + Returns: + The OAuth token, containing access and refresh tokens. + + Raises: + TokenError: If the request is invalid + """ + ... + + async def load_access_token(self, token: str) -> AccessTokenT | None: + """ + Loads an access token by its token. + + Args: + token: The access token to verify. + + Returns: + The AuthInfo, or None if the token is invalid. + """ + + async def revoke_token( + self, + token: AccessTokenT | RefreshTokenT, + ) -> None: + """ + Revokes an access or refresh token. + + If the given token is invalid or already revoked, this method should do nothing. + + Implementations SHOULD revoke both the access token and its corresponding + refresh token, regardless of which of the access token or refresh token is + provided. + + Args: + token: the token to revoke + """ + + +def construct_redirect_uri(redirect_uri_base: str, **params: str | None) -> str: + parsed_uri = urlparse(redirect_uri_base) + query_params = [(k, v) for k, vs in parse_qs(parsed_uri.query).items() for v in vs] + for k, v in params.items(): + if v is not None: + query_params.append((k, v)) + + redirect_uri = urlunparse(parsed_uri._replace(query=urlencode(query_params))) + return redirect_uri + + +class ProviderTokenVerifier(TokenVerifier): + """Token verifier that uses an OAuthAuthorizationServerProvider. + + This is provided for backwards compatibility with existing auth_server_provider + configurations. For new implementations using AS/RS separation, consider using + the TokenVerifier protocol with a dedicated implementation like IntrospectionTokenVerifier. + """ + + def __init__(self, provider: "OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]"): + self.provider = provider + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token using the provider's load_access_token method.""" + return await self.provider.load_access_token(token) diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py new file mode 100644 index 0000000000..71a9c8b165 --- /dev/null +++ b/src/mcp/server/auth/routes.py @@ -0,0 +1,253 @@ +from collections.abc import Awaitable, Callable +from typing import Any +from urllib.parse import urlparse + +from pydantic import AnyHttpUrl +from starlette.middleware.cors import CORSMiddleware +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route, request_response # type: ignore +from starlette.types import ASGIApp + +from mcp.server.auth.handlers.authorize import AuthorizationHandler +from mcp.server.auth.handlers.metadata import MetadataHandler +from mcp.server.auth.handlers.register import RegistrationHandler +from mcp.server.auth.handlers.revoke import RevocationHandler +from mcp.server.auth.handlers.token import TokenHandler +from mcp.server.auth.middleware.client_auth import ClientAuthenticator +from mcp.server.auth.provider import OAuthAuthorizationServerProvider +from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions +from mcp.server.streamable_http import MCP_PROTOCOL_VERSION_HEADER +from mcp.shared.auth import OAuthMetadata + + +def validate_issuer_url(url: AnyHttpUrl): + """ + Validate that the issuer URL meets OAuth 2.0 requirements. + + Args: + url: The issuer URL to validate + + Raises: + ValueError: If the issuer URL is invalid + """ + + # RFC 8414 requires HTTPS, but we allow localhost HTTP for testing + if ( + url.scheme != "https" + and url.host != "localhost" + and (url.host is not None and not url.host.startswith("127.0.0.1")) + ): + raise ValueError("Issuer URL must be HTTPS") # pragma: no cover + + # No fragments or query parameters allowed + if url.fragment: + raise ValueError("Issuer URL must not have a fragment") # pragma: no cover + if url.query: + raise ValueError("Issuer URL must not have a query string") # pragma: no cover + + +AUTHORIZATION_PATH = "/authorize" +TOKEN_PATH = "/token" +REGISTRATION_PATH = "/register" +REVOCATION_PATH = "/revoke" + + +def cors_middleware( + handler: Callable[[Request], Response | Awaitable[Response]], + allow_methods: list[str], +) -> ASGIApp: + cors_app = CORSMiddleware( + app=request_response(handler), + allow_origins="*", + allow_methods=allow_methods, + allow_headers=[MCP_PROTOCOL_VERSION_HEADER], + ) + return cors_app + + +def create_auth_routes( + provider: OAuthAuthorizationServerProvider[Any, Any, Any], + issuer_url: AnyHttpUrl, + service_documentation_url: AnyHttpUrl | None = None, + client_registration_options: ClientRegistrationOptions | None = None, + revocation_options: RevocationOptions | None = None, +) -> list[Route]: + validate_issuer_url(issuer_url) + + client_registration_options = client_registration_options or ClientRegistrationOptions() + revocation_options = revocation_options or RevocationOptions() + metadata = build_metadata( + issuer_url, + service_documentation_url, + client_registration_options, + revocation_options, + ) + client_authenticator = ClientAuthenticator(provider) + + # Create routes + # Allow CORS requests for endpoints meant to be hit by the OAuth client + # (with the client secret). This is intended to support things like MCP Inspector, + # where the client runs in a web browser. + routes = [ + Route( + "/.well-known/oauth-authorization-server", + endpoint=cors_middleware( + MetadataHandler(metadata).handle, + ["GET", "OPTIONS"], + ), + methods=["GET", "OPTIONS"], + ), + Route( + AUTHORIZATION_PATH, + # do not allow CORS for authorization endpoint; + # clients should just redirect to this + endpoint=AuthorizationHandler(provider).handle, + methods=["GET", "POST"], + ), + Route( + TOKEN_PATH, + endpoint=cors_middleware( + TokenHandler(provider, client_authenticator).handle, + ["POST", "OPTIONS"], + ), + methods=["POST", "OPTIONS"], + ), + ] + + if client_registration_options.enabled: # pragma: no branch + registration_handler = RegistrationHandler( + provider, + options=client_registration_options, + ) + routes.append( + Route( + REGISTRATION_PATH, + endpoint=cors_middleware( + registration_handler.handle, + ["POST", "OPTIONS"], + ), + methods=["POST", "OPTIONS"], + ) + ) + + if revocation_options.enabled: # pragma: no branch + revocation_handler = RevocationHandler(provider, client_authenticator) + routes.append( + Route( + REVOCATION_PATH, + endpoint=cors_middleware( + revocation_handler.handle, + ["POST", "OPTIONS"], + ), + methods=["POST", "OPTIONS"], + ) + ) + + return routes + + +def build_metadata( + issuer_url: AnyHttpUrl, + service_documentation_url: AnyHttpUrl | None, + client_registration_options: ClientRegistrationOptions, + revocation_options: RevocationOptions, +) -> OAuthMetadata: + authorization_url = AnyHttpUrl(str(issuer_url).rstrip("/") + AUTHORIZATION_PATH) + token_url = AnyHttpUrl(str(issuer_url).rstrip("/") + TOKEN_PATH) + + # Create metadata + metadata = OAuthMetadata( + issuer=issuer_url, + authorization_endpoint=authorization_url, + token_endpoint=token_url, + scopes_supported=client_registration_options.valid_scopes, + response_types_supported=["code"], + response_modes_supported=None, + grant_types_supported=["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported=["client_secret_post", "client_secret_basic"], + token_endpoint_auth_signing_alg_values_supported=None, + service_documentation=service_documentation_url, + ui_locales_supported=None, + op_policy_uri=None, + op_tos_uri=None, + introspection_endpoint=None, + code_challenge_methods_supported=["S256"], + ) + + # Add registration endpoint if supported + if client_registration_options.enabled: # pragma: no branch + metadata.registration_endpoint = AnyHttpUrl(str(issuer_url).rstrip("/") + REGISTRATION_PATH) + + # Add revocation endpoint if supported + if revocation_options.enabled: # pragma: no branch + metadata.revocation_endpoint = AnyHttpUrl(str(issuer_url).rstrip("/") + REVOCATION_PATH) + metadata.revocation_endpoint_auth_methods_supported = ["client_secret_post", "client_secret_basic"] + + return metadata + + +def build_resource_metadata_url(resource_server_url: AnyHttpUrl) -> AnyHttpUrl: + """ + Build RFC 9728 compliant protected resource metadata URL. + + Inserts /.well-known/oauth-protected-resource between host and resource path + as specified in RFC 9728 §3.1. + + Args: + resource_server_url: The resource server URL (e.g., https://example.com/mcp) + + Returns: + The metadata URL (e.g., https://example.com/.well-known/oauth-protected-resource/mcp) + """ + parsed = urlparse(str(resource_server_url)) + # Handle trailing slash: if path is just "/", treat as empty + resource_path = parsed.path if parsed.path != "/" else "" + return AnyHttpUrl(f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource{resource_path}") + + +def create_protected_resource_routes( + resource_url: AnyHttpUrl, + authorization_servers: list[AnyHttpUrl], + scopes_supported: list[str] | None = None, + resource_name: str | None = None, + resource_documentation: AnyHttpUrl | None = None, +) -> list[Route]: + """ + Create routes for OAuth 2.0 Protected Resource Metadata (RFC 9728). + + Args: + resource_url: The URL of this resource server + authorization_servers: List of authorization servers that can issue tokens + scopes_supported: Optional list of scopes supported by this resource + + Returns: + List of Starlette routes for protected resource metadata + """ + from mcp.server.auth.handlers.metadata import ProtectedResourceMetadataHandler + from mcp.shared.auth import ProtectedResourceMetadata + + metadata = ProtectedResourceMetadata( + resource=resource_url, + authorization_servers=authorization_servers, + scopes_supported=scopes_supported, + resource_name=resource_name, + resource_documentation=resource_documentation, + # bearer_methods_supported defaults to ["header"] in the model + ) + + handler = ProtectedResourceMetadataHandler(metadata) + + # RFC 9728 §3.1: Register route at /.well-known/oauth-protected-resource + resource path + metadata_url = build_resource_metadata_url(resource_url) + # Extract just the path part for route registration + parsed = urlparse(str(metadata_url)) + well_known_path = parsed.path + + return [ + Route( + well_known_path, + endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]), + methods=["GET", "OPTIONS"], + ) + ] diff --git a/src/mcp/server/auth/settings.py b/src/mcp/server/auth/settings.py new file mode 100644 index 0000000000..1649826db2 --- /dev/null +++ b/src/mcp/server/auth/settings.py @@ -0,0 +1,30 @@ +from pydantic import AnyHttpUrl, BaseModel, Field + + +class ClientRegistrationOptions(BaseModel): + enabled: bool = False + client_secret_expiry_seconds: int | None = None + valid_scopes: list[str] | None = None + default_scopes: list[str] | None = None + + +class RevocationOptions(BaseModel): + enabled: bool = False + + +class AuthSettings(BaseModel): + issuer_url: AnyHttpUrl = Field( + ..., + description="OAuth authorization server URL that issues tokens for this resource server.", + ) + service_documentation_url: AnyHttpUrl | None = None + client_registration_options: ClientRegistrationOptions | None = None + revocation_options: RevocationOptions | None = None + required_scopes: list[str] | None = None + + # Resource Server settings (when operating as RS only) + resource_server_url: AnyHttpUrl | None = Field( + ..., + description="The URL of the MCP server to be used as the resource identifier " + "and base route to look up OAuth Protected Resource Metadata.", + ) diff --git a/src/mcp/server/elicitation.py b/src/mcp/server/elicitation.py new file mode 100644 index 0000000000..49195415bf --- /dev/null +++ b/src/mcp/server/elicitation.py @@ -0,0 +1,190 @@ +"""Elicitation utilities for MCP servers.""" + +from __future__ import annotations + +import types +from collections.abc import Sequence +from typing import Generic, Literal, TypeVar, Union, get_args, get_origin + +from pydantic import BaseModel + +from mcp.server.session import ServerSession +from mcp.types import RequestId + +ElicitSchemaModelT = TypeVar("ElicitSchemaModelT", bound=BaseModel) + + +class AcceptedElicitation(BaseModel, Generic[ElicitSchemaModelT]): + """Result when user accepts the elicitation.""" + + action: Literal["accept"] = "accept" + data: ElicitSchemaModelT + + +class DeclinedElicitation(BaseModel): + """Result when user declines the elicitation.""" + + action: Literal["decline"] = "decline" + + +class CancelledElicitation(BaseModel): + """Result when user cancels the elicitation.""" + + action: Literal["cancel"] = "cancel" + + +ElicitationResult = AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation + + +class AcceptedUrlElicitation(BaseModel): + """Result when user accepts a URL mode elicitation.""" + + action: Literal["accept"] = "accept" + + +UrlElicitationResult = AcceptedUrlElicitation | DeclinedElicitation | CancelledElicitation + + +# Primitive types allowed in elicitation schemas +_ELICITATION_PRIMITIVE_TYPES = (str, int, float, bool) + + +def _validate_elicitation_schema(schema: type[BaseModel]) -> None: + """Validate that a Pydantic model only contains primitive field types.""" + for field_name, field_info in schema.model_fields.items(): + annotation = field_info.annotation + + if annotation is None or annotation is types.NoneType: # pragma: no cover + continue + elif _is_primitive_field(annotation): + continue + elif _is_string_sequence(annotation): + continue + else: + raise TypeError( + f"Elicitation schema field '{field_name}' must be a primitive type " + f"{_ELICITATION_PRIMITIVE_TYPES}, a sequence of strings (list[str], etc.), " + f"or Optional of these types. Nested models and complex types are not allowed." + ) + + +def _is_string_sequence(annotation: type) -> bool: + """Check if annotation is a sequence of strings (list[str], Sequence[str], etc).""" + origin = get_origin(annotation) + # Check if it's a sequence-like type with str elements + if origin: + try: + if issubclass(origin, Sequence): + args = get_args(annotation) + # Should have single str type arg + return len(args) == 1 and args[0] is str + except TypeError: # pragma: no cover + # origin is not a class, so it can't be a subclass of Sequence + pass + return False + + +def _is_primitive_field(annotation: type) -> bool: + """Check if a field is a primitive type allowed in elicitation schemas.""" + # Handle basic primitive types + if annotation in _ELICITATION_PRIMITIVE_TYPES: + return True + + # Handle Union types + origin = get_origin(annotation) + if origin is Union or origin is types.UnionType: + args = get_args(annotation) + # All args must be primitive types, None, or string sequences + return all( + arg is types.NoneType or arg in _ELICITATION_PRIMITIVE_TYPES or _is_string_sequence(arg) for arg in args + ) + + return False + + +async def elicit_with_validation( + session: ServerSession, + message: str, + schema: type[ElicitSchemaModelT], + related_request_id: RequestId | None = None, +) -> ElicitationResult[ElicitSchemaModelT]: + """Elicit information from the client/user with schema validation (form mode). + + This method can be used to interactively ask for additional information from the + client within a tool's execution. The client might display the message to the + user and collect a response according to the provided schema. Or in case a + client is an agent, it might decide how to handle the elicitation -- either by asking + the user or automatically generating a response. + + For sensitive data like credentials or OAuth flows, use elicit_url() instead. + """ + # Validate that schema only contains primitive types and fail loudly if not + _validate_elicitation_schema(schema) + + json_schema = schema.model_json_schema() + + result = await session.elicit_form( + message=message, + requestedSchema=json_schema, + related_request_id=related_request_id, + ) + + if result.action == "accept" and result.content is not None: + # Validate and parse the content using the schema + validated_data = schema.model_validate(result.content) + return AcceptedElicitation(data=validated_data) + elif result.action == "decline": + return DeclinedElicitation() + elif result.action == "cancel": # pragma: no cover + return CancelledElicitation() + else: # pragma: no cover + # This should never happen, but handle it just in case + raise ValueError(f"Unexpected elicitation action: {result.action}") + + +async def elicit_url( + session: ServerSession, + message: str, + url: str, + elicitation_id: str, + related_request_id: RequestId | None = None, +) -> UrlElicitationResult: + """Elicit information from the user via out-of-band URL navigation (URL mode). + + This method directs the user to an external URL where sensitive interactions can + occur without passing data through the MCP client. Use this for: + - Collecting sensitive credentials (API keys, passwords) + - OAuth authorization flows with third-party services + - Payment and subscription flows + - Any interaction where data should not pass through the LLM context + + The response indicates whether the user consented to navigate to the URL. + The actual interaction happens out-of-band. When the elicitation completes, + the server should send an ElicitCompleteNotification to notify the client. + + Args: + session: The server session + message: Human-readable explanation of why the interaction is needed + url: The URL the user should navigate to + elicitation_id: Unique identifier for tracking this elicitation + related_request_id: Optional ID of the request that triggered this elicitation + + Returns: + UrlElicitationResult indicating accept, decline, or cancel + """ + result = await session.elicit_url( + message=message, + url=url, + elicitation_id=elicitation_id, + related_request_id=related_request_id, + ) + + if result.action == "accept": + return AcceptedUrlElicitation() + elif result.action == "decline": + return DeclinedElicitation() + elif result.action == "cancel": + return CancelledElicitation() + else: # pragma: no cover + # This should never happen, but handle it just in case + raise ValueError(f"Unexpected elicitation action: {result.action}") diff --git a/src/mcp/server/experimental/__init__.py b/src/mcp/server/experimental/__init__.py new file mode 100644 index 0000000000..824bb8b8be --- /dev/null +++ b/src/mcp/server/experimental/__init__.py @@ -0,0 +1,11 @@ +""" +Server-side experimental features. + +WARNING: These APIs are experimental and may change without notice. + +Import directly from submodules: +- mcp.server.experimental.task_context.ServerTaskContext +- mcp.server.experimental.task_support.TaskSupport +- mcp.server.experimental.task_result_handler.TaskResultHandler +- mcp.server.experimental.request_context.Experimental +""" diff --git a/src/mcp/server/experimental/request_context.py b/src/mcp/server/experimental/request_context.py new file mode 100644 index 0000000000..78e75beb6a --- /dev/null +++ b/src/mcp/server/experimental/request_context.py @@ -0,0 +1,238 @@ +""" +Experimental request context features. + +This module provides the Experimental class which gives access to experimental +features within a request context, such as task-augmented request handling. + +WARNING: These APIs are experimental and may change without notice. +""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any + +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.server.experimental.task_support import TaskSupport +from mcp.server.session import ServerSession +from mcp.shared.exceptions import McpError +from mcp.shared.experimental.tasks.helpers import MODEL_IMMEDIATE_RESPONSE_KEY, is_terminal +from mcp.types import ( + METHOD_NOT_FOUND, + TASK_FORBIDDEN, + TASK_REQUIRED, + ClientCapabilities, + CreateTaskResult, + ErrorData, + Result, + TaskExecutionMode, + TaskMetadata, + Tool, +) + + +@dataclass +class Experimental: + """ + Experimental features context for task-augmented requests. + + Provides helpers for validating task execution compatibility and + running tasks with automatic lifecycle management. + + WARNING: This API is experimental and may change without notice. + """ + + task_metadata: TaskMetadata | None = None + _client_capabilities: ClientCapabilities | None = field(default=None, repr=False) + _session: ServerSession | None = field(default=None, repr=False) + _task_support: TaskSupport | None = field(default=None, repr=False) + + @property + def is_task(self) -> bool: + """Check if this request is task-augmented.""" + return self.task_metadata is not None + + @property + def client_supports_tasks(self) -> bool: + """Check if the client declared task support.""" + if self._client_capabilities is None: + return False + return self._client_capabilities.tasks is not None + + def validate_task_mode( + self, + tool_task_mode: TaskExecutionMode | None, + *, + raise_error: bool = True, + ) -> ErrorData | None: + """ + Validate that the request is compatible with the tool's task execution mode. + + Per MCP spec: + - "required": Clients MUST invoke as task. Server returns -32601 if not. + - "forbidden" (or None): Clients MUST NOT invoke as task. Server returns -32601 if they do. + - "optional": Either is acceptable. + + Args: + tool_task_mode: The tool's execution.taskSupport value + ("forbidden", "optional", "required", or None) + raise_error: If True, raises McpError on validation failure. If False, returns ErrorData. + + Returns: + None if valid, ErrorData if invalid and raise_error=False + + Raises: + McpError: If invalid and raise_error=True + """ + + mode = tool_task_mode or TASK_FORBIDDEN + + error: ErrorData | None = None + + if mode == TASK_REQUIRED and not self.is_task: + error = ErrorData( + code=METHOD_NOT_FOUND, + message="This tool requires task-augmented invocation", + ) + elif mode == TASK_FORBIDDEN and self.is_task: + error = ErrorData( + code=METHOD_NOT_FOUND, + message="This tool does not support task-augmented invocation", + ) + + if error is not None and raise_error: + raise McpError(error) + + return error + + def validate_for_tool( + self, + tool: Tool, + *, + raise_error: bool = True, + ) -> ErrorData | None: + """ + Validate that the request is compatible with the given tool. + + Convenience wrapper around validate_task_mode that extracts the mode from a Tool. + + Args: + tool: The Tool definition + raise_error: If True, raises McpError on validation failure. + + Returns: + None if valid, ErrorData if invalid and raise_error=False + """ + mode = tool.execution.taskSupport if tool.execution else None + return self.validate_task_mode(mode, raise_error=raise_error) + + def can_use_tool(self, tool_task_mode: TaskExecutionMode | None) -> bool: + """ + Check if this client can use a tool with the given task mode. + + Useful for filtering tool lists or providing warnings. + Returns False if tool requires "required" but client doesn't support tasks. + + Args: + tool_task_mode: The tool's execution.taskSupport value + + Returns: + True if the client can use this tool, False otherwise + """ + mode = tool_task_mode or TASK_FORBIDDEN + if mode == TASK_REQUIRED and not self.client_supports_tasks: + return False + return True + + async def run_task( + self, + work: Callable[[ServerTaskContext], Awaitable[Result]], + *, + task_id: str | None = None, + model_immediate_response: str | None = None, + ) -> CreateTaskResult: + """ + Create a task, spawn background work, and return CreateTaskResult immediately. + + This is the recommended way to handle task-augmented tool calls. It: + 1. Creates a task in the store + 2. Spawns the work function in a background task + 3. Returns CreateTaskResult immediately + + The work function receives a ServerTaskContext with: + - elicit() for sending elicitation requests + - create_message() for sampling requests + - update_status() for progress updates + - complete()/fail() for finishing the task + + When work() returns a Result, the task is auto-completed with that result. + If work() raises an exception, the task is auto-failed. + + Args: + work: Async function that does the actual work + task_id: Optional task ID (generated if not provided) + model_immediate_response: Optional string to include in _meta as + io.modelcontextprotocol/model-immediate-response + + Returns: + CreateTaskResult to return to the client + + Raises: + RuntimeError: If task support is not enabled or task_metadata is missing + + Example: + @server.call_tool() + async def handle_tool(name: str, args: dict): + ctx = server.request_context + + async def work(task: ServerTaskContext) -> CallToolResult: + result = await task.elicit( + message="Are you sure?", + requestedSchema={"type": "object", ...} + ) + confirmed = result.content.get("confirm", False) + return CallToolResult(content=[TextContent(text="Done" if confirmed else "Cancelled")]) + + return await ctx.experimental.run_task(work) + + WARNING: This API is experimental and may change without notice. + """ + if self._task_support is None: + raise RuntimeError("Task support not enabled. Call server.experimental.enable_tasks() first.") + if self._session is None: + raise RuntimeError("Session not available.") + if self.task_metadata is None: + raise RuntimeError( + "Request is not task-augmented (no task field in params). " + "The client must send a task-augmented request." + ) + + support = self._task_support + # Access task_group via TaskSupport - raises if not in run() context + task_group = support.task_group + + task = await support.store.create_task(self.task_metadata, task_id) + + task_ctx = ServerTaskContext( + task=task, + store=support.store, + session=self._session, + queue=support.queue, + handler=support.handler, + ) + + async def execute() -> None: + try: + result = await work(task_ctx) + if not is_terminal(task_ctx.task.status): + await task_ctx.complete(result) + except Exception as e: + if not is_terminal(task_ctx.task.status): + await task_ctx.fail(str(e)) + + task_group.start_soon(execute) + + meta: dict[str, Any] | None = None + if model_immediate_response is not None: + meta = {MODEL_IMMEDIATE_RESPONSE_KEY: model_immediate_response} + + return CreateTaskResult(task=task, **{"_meta": meta} if meta else {}) diff --git a/src/mcp/server/experimental/session_features.py b/src/mcp/server/experimental/session_features.py new file mode 100644 index 0000000000..4842da5175 --- /dev/null +++ b/src/mcp/server/experimental/session_features.py @@ -0,0 +1,220 @@ +""" +Experimental server session features for server→client task operations. + +This module provides the server-side equivalent of ExperimentalClientFeatures, +allowing the server to send task-augmented requests to the client and poll for results. + +WARNING: These APIs are experimental and may change without notice. +""" + +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any, TypeVar + +import mcp.types as types +from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages +from mcp.shared.experimental.tasks.capabilities import ( + require_task_augmented_elicitation, + require_task_augmented_sampling, +) +from mcp.shared.experimental.tasks.polling import poll_until_terminal + +if TYPE_CHECKING: + from mcp.server.session import ServerSession + +ResultT = TypeVar("ResultT", bound=types.Result) + + +class ExperimentalServerSessionFeatures: + """ + Experimental server session features for server→client task operations. + + This provides the server-side equivalent of ExperimentalClientFeatures, + allowing the server to send task-augmented requests to the client and + poll for results. + + WARNING: These APIs are experimental and may change without notice. + + Access via session.experimental: + result = await session.experimental.elicit_as_task(...) + """ + + def __init__(self, session: "ServerSession") -> None: + self._session = session + + async def get_task(self, task_id: str) -> types.GetTaskResult: + """ + Send tasks/get to the client to get task status. + + Args: + task_id: The task identifier + + Returns: + GetTaskResult containing the task status + """ + return await self._session.send_request( + types.ServerRequest(types.GetTaskRequest(params=types.GetTaskRequestParams(taskId=task_id))), + types.GetTaskResult, + ) + + async def get_task_result( + self, + task_id: str, + result_type: type[ResultT], + ) -> ResultT: + """ + Send tasks/result to the client to retrieve the final result. + + Args: + task_id: The task identifier + result_type: The expected result type + + Returns: + The task result, validated against result_type + """ + return await self._session.send_request( + types.ServerRequest(types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(taskId=task_id))), + result_type, + ) + + async def poll_task(self, task_id: str) -> AsyncIterator[types.GetTaskResult]: + """ + Poll a client task until it reaches terminal status. + + Yields GetTaskResult for each poll, allowing the caller to react to + status changes. Exits when task reaches a terminal status. + + Respects the pollInterval hint from the client. + + Args: + task_id: The task identifier + + Yields: + GetTaskResult for each poll + """ + async for status in poll_until_terminal(self.get_task, task_id): + yield status + + async def elicit_as_task( + self, + message: str, + requestedSchema: types.ElicitRequestedSchema, + *, + ttl: int = 60000, + ) -> types.ElicitResult: + """ + Send a task-augmented elicitation to the client and poll until complete. + + The client will create a local task, process the elicitation asynchronously, + and return the result when ready. This method handles the full flow: + 1. Send elicitation with task field + 2. Receive CreateTaskResult from client + 3. Poll client's task until terminal + 4. Retrieve and return the final ElicitResult + + Args: + message: The message to present to the user + requestedSchema: Schema defining the expected response + ttl: Task time-to-live in milliseconds + + Returns: + The client's elicitation response + + Raises: + McpError: If client doesn't support task-augmented elicitation + """ + client_caps = self._session.client_params.capabilities if self._session.client_params else None + require_task_augmented_elicitation(client_caps) + + create_result = await self._session.send_request( + types.ServerRequest( + types.ElicitRequest( + params=types.ElicitRequestFormParams( + message=message, + requestedSchema=requestedSchema, + task=types.TaskMetadata(ttl=ttl), + ) + ) + ), + types.CreateTaskResult, + ) + + task_id = create_result.task.taskId + + async for _ in self.poll_task(task_id): + pass + + return await self.get_task_result(task_id, types.ElicitResult) + + async def create_message_as_task( + self, + messages: list[types.SamplingMessage], + *, + max_tokens: int, + ttl: int = 60000, + system_prompt: str | None = None, + include_context: types.IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: types.ModelPreferences | None = None, + tools: list[types.Tool] | None = None, + tool_choice: types.ToolChoice | None = None, + ) -> types.CreateMessageResult: + """ + Send a task-augmented sampling request and poll until complete. + + The client will create a local task, process the sampling request + asynchronously, and return the result when ready. + + Args: + messages: The conversation messages for sampling + max_tokens: Maximum tokens in the response + ttl: Task time-to-live in milliseconds + system_prompt: Optional system prompt + include_context: Context inclusion strategy + temperature: Sampling temperature + stop_sequences: Stop sequences + metadata: Additional metadata + model_preferences: Model selection preferences + tools: Optional list of tools the LLM can use during sampling + tool_choice: Optional control over tool usage behavior + + Returns: + The sampling result from the client + + Raises: + McpError: If client doesn't support task-augmented sampling or tools + ValueError: If tool_use or tool_result message structure is invalid + """ + client_caps = self._session.client_params.capabilities if self._session.client_params else None + require_task_augmented_sampling(client_caps) + validate_sampling_tools(client_caps, tools, tool_choice) + validate_tool_use_result_messages(messages) + + create_result = await self._session.send_request( + types.ServerRequest( + types.CreateMessageRequest( + params=types.CreateMessageRequestParams( + messages=messages, + maxTokens=max_tokens, + systemPrompt=system_prompt, + includeContext=include_context, + temperature=temperature, + stopSequences=stop_sequences, + metadata=metadata, + modelPreferences=model_preferences, + tools=tools, + toolChoice=tool_choice, + task=types.TaskMetadata(ttl=ttl), + ) + ) + ), + types.CreateTaskResult, + ) + + task_id = create_result.task.taskId + + async for _ in self.poll_task(task_id): + pass + + return await self.get_task_result(task_id, types.CreateMessageResult) diff --git a/src/mcp/server/experimental/task_context.py b/src/mcp/server/experimental/task_context.py new file mode 100644 index 0000000000..e6e14fc938 --- /dev/null +++ b/src/mcp/server/experimental/task_context.py @@ -0,0 +1,612 @@ +""" +ServerTaskContext - Server-integrated task context with elicitation and sampling. + +This wraps the pure TaskContext and adds server-specific functionality: +- Elicitation (task.elicit()) +- Sampling (task.create_message()) +- Status notifications +""" + +from typing import Any + +import anyio + +from mcp.server.experimental.task_result_handler import TaskResultHandler +from mcp.server.session import ServerSession +from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages +from mcp.shared.exceptions import McpError +from mcp.shared.experimental.tasks.capabilities import ( + require_task_augmented_elicitation, + require_task_augmented_sampling, +) +from mcp.shared.experimental.tasks.context import TaskContext +from mcp.shared.experimental.tasks.message_queue import QueuedMessage, TaskMessageQueue +from mcp.shared.experimental.tasks.resolver import Resolver +from mcp.shared.experimental.tasks.store import TaskStore +from mcp.types import ( + INVALID_REQUEST, + TASK_STATUS_INPUT_REQUIRED, + TASK_STATUS_WORKING, + ClientCapabilities, + CreateMessageResult, + CreateTaskResult, + ElicitationCapability, + ElicitRequestedSchema, + ElicitResult, + ErrorData, + IncludeContext, + ModelPreferences, + RequestId, + Result, + SamplingCapability, + SamplingMessage, + ServerNotification, + Task, + TaskMetadata, + TaskStatusNotification, + TaskStatusNotificationParams, + Tool, + ToolChoice, +) + + +class ServerTaskContext: + """ + Server-integrated task context with elicitation and sampling. + + This wraps a pure TaskContext and adds server-specific functionality: + - elicit() for sending elicitation requests to the client + - create_message() for sampling requests + - Status notifications via the session + + Example: + async def my_task_work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Starting...") + + result = await task.elicit( + message="Continue?", + requestedSchema={"type": "object", "properties": {"ok": {"type": "boolean"}}} + ) + + if result.content.get("ok"): + return CallToolResult(content=[TextContent(text="Done!")]) + else: + return CallToolResult(content=[TextContent(text="Cancelled")]) + """ + + def __init__( + self, + *, + task: Task, + store: TaskStore, + session: ServerSession, + queue: TaskMessageQueue, + handler: TaskResultHandler | None = None, + ): + """ + Create a ServerTaskContext. + + Args: + task: The Task object + store: The task store + session: The server session + queue: The message queue for elicitation/sampling + handler: The result handler for response routing (required for elicit/create_message) + """ + self._ctx = TaskContext(task=task, store=store) + self._session = session + self._queue = queue + self._handler = handler + self._store = store + + # Delegate pure properties to inner context + + @property + def task_id(self) -> str: + """The task identifier.""" + return self._ctx.task_id + + @property + def task(self) -> Task: + """The current task state.""" + return self._ctx.task + + @property + def is_cancelled(self) -> bool: + """Whether cancellation has been requested.""" + return self._ctx.is_cancelled + + def request_cancellation(self) -> None: + """Request cancellation of this task.""" + self._ctx.request_cancellation() + + # Enhanced methods with notifications + + async def update_status(self, message: str, *, notify: bool = True) -> None: + """ + Update the task's status message. + + Args: + message: The new status message + notify: Whether to send a notification to the client + """ + await self._ctx.update_status(message) + if notify: + await self._send_notification() + + async def complete(self, result: Result, *, notify: bool = True) -> None: + """ + Mark the task as completed with the given result. + + Args: + result: The task result + notify: Whether to send a notification to the client + """ + await self._ctx.complete(result) + if notify: + await self._send_notification() + + async def fail(self, error: str, *, notify: bool = True) -> None: + """ + Mark the task as failed with an error message. + + Args: + error: The error message + notify: Whether to send a notification to the client + """ + await self._ctx.fail(error) + if notify: + await self._send_notification() + + async def _send_notification(self) -> None: + """Send a task status notification to the client.""" + task = self._ctx.task + await self._session.send_notification( + ServerNotification( + TaskStatusNotification( + params=TaskStatusNotificationParams( + taskId=task.taskId, + status=task.status, + statusMessage=task.statusMessage, + createdAt=task.createdAt, + lastUpdatedAt=task.lastUpdatedAt, + ttl=task.ttl, + pollInterval=task.pollInterval, + ) + ) + ) + ) + + # Server-specific methods: elicitation and sampling + + def _check_elicitation_capability(self) -> None: + """Check if the client supports elicitation.""" + if not self._session.check_client_capability(ClientCapabilities(elicitation=ElicitationCapability())): + raise McpError( + ErrorData( + code=INVALID_REQUEST, + message="Client does not support elicitation capability", + ) + ) + + def _check_sampling_capability(self) -> None: + """Check if the client supports sampling.""" + if not self._session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())): + raise McpError( + ErrorData( + code=INVALID_REQUEST, + message="Client does not support sampling capability", + ) + ) + + async def elicit( + self, + message: str, + requestedSchema: ElicitRequestedSchema, + ) -> ElicitResult: + """ + Send an elicitation request via the task message queue. + + This method: + 1. Checks client capability + 2. Updates task status to "input_required" + 3. Queues the elicitation request + 4. Waits for the response (delivered via tasks/result round-trip) + 5. Updates task status back to "working" + 6. Returns the result + + Args: + message: The message to present to the user + requestedSchema: Schema defining the expected response structure + + Returns: + The client's response + + Raises: + McpError: If client doesn't support elicitation capability + """ + self._check_elicitation_capability() + + if self._handler is None: + raise RuntimeError("handler is required for elicit(). Pass handler= to ServerTaskContext.") + + # Update status to input_required + await self._store.update_task(self.task_id, status=TASK_STATUS_INPUT_REQUIRED) + + # Build the request using session's helper + request = self._session._build_elicit_form_request( # pyright: ignore[reportPrivateUsage] + message=message, + requestedSchema=requestedSchema, + related_task_id=self.task_id, + ) + request_id: RequestId = request.id + + resolver: Resolver[dict[str, Any]] = Resolver() + self._handler._pending_requests[request_id] = resolver # pyright: ignore[reportPrivateUsage] + + queued = QueuedMessage( + type="request", + message=request, + resolver=resolver, + original_request_id=request_id, + ) + await self._queue.enqueue(self.task_id, queued) + + try: + # Wait for response (routed back via TaskResultHandler) + response_data = await resolver.wait() + await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) + return ElicitResult.model_validate(response_data) + except anyio.get_cancelled_exc_class(): # pragma: no cover + # Coverage can't track async exception handlers reliably. + # This path is tested in test_elicit_restores_status_on_cancellation + # which verifies status is restored to "working" after cancellation. + await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) + raise + + async def elicit_url( + self, + message: str, + url: str, + elicitation_id: str, + ) -> ElicitResult: + """ + Send a URL mode elicitation request via the task message queue. + + This directs the user to an external URL for out-of-band interactions + like OAuth flows, credential collection, or payment processing. + + This method: + 1. Checks client capability + 2. Updates task status to "input_required" + 3. Queues the elicitation request + 4. Waits for the response (delivered via tasks/result round-trip) + 5. Updates task status back to "working" + 6. Returns the result + + Args: + message: Human-readable explanation of why the interaction is needed + url: The URL the user should navigate to + elicitation_id: Unique identifier for tracking this elicitation + + Returns: + The client's response indicating acceptance, decline, or cancellation + + Raises: + McpError: If client doesn't support elicitation capability + RuntimeError: If handler is not configured + """ + self._check_elicitation_capability() + + if self._handler is None: + raise RuntimeError("handler is required for elicit_url(). Pass handler= to ServerTaskContext.") + + # Update status to input_required + await self._store.update_task(self.task_id, status=TASK_STATUS_INPUT_REQUIRED) + + # Build the request using session's helper + request = self._session._build_elicit_url_request( # pyright: ignore[reportPrivateUsage] + message=message, + url=url, + elicitation_id=elicitation_id, + related_task_id=self.task_id, + ) + request_id: RequestId = request.id + + resolver: Resolver[dict[str, Any]] = Resolver() + self._handler._pending_requests[request_id] = resolver # pyright: ignore[reportPrivateUsage] + + queued = QueuedMessage( + type="request", + message=request, + resolver=resolver, + original_request_id=request_id, + ) + await self._queue.enqueue(self.task_id, queued) + + try: + # Wait for response (routed back via TaskResultHandler) + response_data = await resolver.wait() + await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) + return ElicitResult.model_validate(response_data) + except anyio.get_cancelled_exc_class(): # pragma: no cover + await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) + raise + + async def create_message( + self, + messages: list[SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: ModelPreferences | None = None, + tools: list[Tool] | None = None, + tool_choice: ToolChoice | None = None, + ) -> CreateMessageResult: + """ + Send a sampling request via the task message queue. + + This method: + 1. Checks client capability + 2. Updates task status to "input_required" + 3. Queues the sampling request + 4. Waits for the response (delivered via tasks/result round-trip) + 5. Updates task status back to "working" + 6. Returns the result + + Args: + messages: The conversation messages for sampling + max_tokens: Maximum tokens in the response + system_prompt: Optional system prompt + include_context: Context inclusion strategy + temperature: Sampling temperature + stop_sequences: Stop sequences + metadata: Additional metadata + model_preferences: Model selection preferences + tools: Optional list of tools the LLM can use during sampling + tool_choice: Optional control over tool usage behavior + + Returns: + The sampling result from the client + + Raises: + McpError: If client doesn't support sampling capability or tools + ValueError: If tool_use or tool_result message structure is invalid + """ + self._check_sampling_capability() + client_caps = self._session.client_params.capabilities if self._session.client_params else None + validate_sampling_tools(client_caps, tools, tool_choice) + validate_tool_use_result_messages(messages) + + if self._handler is None: + raise RuntimeError("handler is required for create_message(). Pass handler= to ServerTaskContext.") + + # Update status to input_required + await self._store.update_task(self.task_id, status=TASK_STATUS_INPUT_REQUIRED) + + # Build the request using session's helper + request = self._session._build_create_message_request( # pyright: ignore[reportPrivateUsage] + messages=messages, + max_tokens=max_tokens, + system_prompt=system_prompt, + include_context=include_context, + temperature=temperature, + stop_sequences=stop_sequences, + metadata=metadata, + model_preferences=model_preferences, + tools=tools, + tool_choice=tool_choice, + related_task_id=self.task_id, + ) + request_id: RequestId = request.id + + resolver: Resolver[dict[str, Any]] = Resolver() + self._handler._pending_requests[request_id] = resolver # pyright: ignore[reportPrivateUsage] + + queued = QueuedMessage( + type="request", + message=request, + resolver=resolver, + original_request_id=request_id, + ) + await self._queue.enqueue(self.task_id, queued) + + try: + # Wait for response (routed back via TaskResultHandler) + response_data = await resolver.wait() + await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) + return CreateMessageResult.model_validate(response_data) + except anyio.get_cancelled_exc_class(): # pragma: no cover + # Coverage can't track async exception handlers reliably. + # This path is tested in test_create_message_restores_status_on_cancellation + # which verifies status is restored to "working" after cancellation. + await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) + raise + + async def elicit_as_task( + self, + message: str, + requestedSchema: ElicitRequestedSchema, + *, + ttl: int = 60000, + ) -> ElicitResult: + """ + Send a task-augmented elicitation via the queue, then poll client. + + This is for use inside a task-augmented tool call when you want the client + to handle the elicitation as its own task. The elicitation request is queued + and delivered when the client calls tasks/result. After the client responds + with CreateTaskResult, we poll the client's task until complete. + + Args: + message: The message to present to the user + requestedSchema: Schema defining the expected response structure + ttl: Task time-to-live in milliseconds for the client's task + + Returns: + The client's elicitation response + + Raises: + McpError: If client doesn't support task-augmented elicitation + RuntimeError: If handler is not configured + """ + client_caps = self._session.client_params.capabilities if self._session.client_params else None + require_task_augmented_elicitation(client_caps) + + if self._handler is None: + raise RuntimeError("handler is required for elicit_as_task()") + + # Update status to input_required + await self._store.update_task(self.task_id, status=TASK_STATUS_INPUT_REQUIRED) + + request = self._session._build_elicit_form_request( # pyright: ignore[reportPrivateUsage] + message=message, + requestedSchema=requestedSchema, + related_task_id=self.task_id, + task=TaskMetadata(ttl=ttl), + ) + request_id: RequestId = request.id + + resolver: Resolver[dict[str, Any]] = Resolver() + self._handler._pending_requests[request_id] = resolver # pyright: ignore[reportPrivateUsage] + + queued = QueuedMessage( + type="request", + message=request, + resolver=resolver, + original_request_id=request_id, + ) + await self._queue.enqueue(self.task_id, queued) + + try: + # Wait for initial response (CreateTaskResult from client) + response_data = await resolver.wait() + create_result = CreateTaskResult.model_validate(response_data) + client_task_id = create_result.task.taskId + + # Poll the client's task using session.experimental + async for _ in self._session.experimental.poll_task(client_task_id): + pass + + # Get final result from client + result = await self._session.experimental.get_task_result( + client_task_id, + ElicitResult, + ) + + await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) + return result + + except anyio.get_cancelled_exc_class(): # pragma: no cover + await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) + raise + + async def create_message_as_task( + self, + messages: list[SamplingMessage], + *, + max_tokens: int, + ttl: int = 60000, + system_prompt: str | None = None, + include_context: IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: ModelPreferences | None = None, + tools: list[Tool] | None = None, + tool_choice: ToolChoice | None = None, + ) -> CreateMessageResult: + """ + Send a task-augmented sampling request via the queue, then poll client. + + This is for use inside a task-augmented tool call when you want the client + to handle the sampling as its own task. The request is queued and delivered + when the client calls tasks/result. After the client responds with + CreateTaskResult, we poll the client's task until complete. + + Args: + messages: The conversation messages for sampling + max_tokens: Maximum tokens in the response + ttl: Task time-to-live in milliseconds for the client's task + system_prompt: Optional system prompt + include_context: Context inclusion strategy + temperature: Sampling temperature + stop_sequences: Stop sequences + metadata: Additional metadata + model_preferences: Model selection preferences + tools: Optional list of tools the LLM can use during sampling + tool_choice: Optional control over tool usage behavior + + Returns: + The sampling result from the client + + Raises: + McpError: If client doesn't support task-augmented sampling or tools + ValueError: If tool_use or tool_result message structure is invalid + RuntimeError: If handler is not configured + """ + client_caps = self._session.client_params.capabilities if self._session.client_params else None + require_task_augmented_sampling(client_caps) + validate_sampling_tools(client_caps, tools, tool_choice) + validate_tool_use_result_messages(messages) + + if self._handler is None: + raise RuntimeError("handler is required for create_message_as_task()") + + # Update status to input_required + await self._store.update_task(self.task_id, status=TASK_STATUS_INPUT_REQUIRED) + + # Build request WITH task field for task-augmented sampling + request = self._session._build_create_message_request( # pyright: ignore[reportPrivateUsage] + messages=messages, + max_tokens=max_tokens, + system_prompt=system_prompt, + include_context=include_context, + temperature=temperature, + stop_sequences=stop_sequences, + metadata=metadata, + model_preferences=model_preferences, + tools=tools, + tool_choice=tool_choice, + related_task_id=self.task_id, + task=TaskMetadata(ttl=ttl), + ) + request_id: RequestId = request.id + + resolver: Resolver[dict[str, Any]] = Resolver() + self._handler._pending_requests[request_id] = resolver # pyright: ignore[reportPrivateUsage] + + queued = QueuedMessage( + type="request", + message=request, + resolver=resolver, + original_request_id=request_id, + ) + await self._queue.enqueue(self.task_id, queued) + + try: + # Wait for initial response (CreateTaskResult from client) + response_data = await resolver.wait() + create_result = CreateTaskResult.model_validate(response_data) + client_task_id = create_result.task.taskId + + # Poll the client's task using session.experimental + async for _ in self._session.experimental.poll_task(client_task_id): + pass + + # Get final result from client + result = await self._session.experimental.get_task_result( + client_task_id, + CreateMessageResult, + ) + + await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) + return result + + except anyio.get_cancelled_exc_class(): # pragma: no cover + await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) + raise diff --git a/src/mcp/server/experimental/task_result_handler.py b/src/mcp/server/experimental/task_result_handler.py new file mode 100644 index 0000000000..0b869216e8 --- /dev/null +++ b/src/mcp/server/experimental/task_result_handler.py @@ -0,0 +1,235 @@ +""" +TaskResultHandler - Integrated handler for tasks/result endpoint. + +This implements the dequeue-send-wait pattern from the MCP Tasks spec: +1. Dequeue all pending messages for the task +2. Send them to the client via transport with relatedRequestId routing +3. Wait if task is not in terminal state +4. Return final result when task completes + +This is the core of the task message queue pattern. +""" + +import logging +from typing import Any + +import anyio + +from mcp.server.session import ServerSession +from mcp.shared.exceptions import McpError +from mcp.shared.experimental.tasks.helpers import RELATED_TASK_METADATA_KEY, is_terminal +from mcp.shared.experimental.tasks.message_queue import TaskMessageQueue +from mcp.shared.experimental.tasks.resolver import Resolver +from mcp.shared.experimental.tasks.store import TaskStore +from mcp.shared.message import ServerMessageMetadata, SessionMessage +from mcp.types import ( + INVALID_PARAMS, + ErrorData, + GetTaskPayloadRequest, + GetTaskPayloadResult, + JSONRPCMessage, + RelatedTaskMetadata, + RequestId, +) + +logger = logging.getLogger(__name__) + + +class TaskResultHandler: + """ + Handler for tasks/result that implements the message queue pattern. + + This handler: + 1. Dequeues pending messages (elicitations, notifications) for the task + 2. Sends them to the client via the response stream + 3. Waits for responses and resolves them back to callers + 4. Blocks until task reaches terminal state + 5. Returns the final result + + Usage: + # Create handler with store and queue + handler = TaskResultHandler(task_store, message_queue) + + # Register it with the server + @server.experimental.get_task_result() + async def handle_task_result(req: GetTaskPayloadRequest) -> GetTaskPayloadResult: + ctx = server.request_context + return await handler.handle(req, ctx.session, ctx.request_id) + + # Or use the convenience method + handler.register(server) + """ + + def __init__( + self, + store: TaskStore, + queue: TaskMessageQueue, + ): + self._store = store + self._queue = queue + # Map from internal request ID to resolver for routing responses + self._pending_requests: dict[RequestId, Resolver[dict[str, Any]]] = {} + + async def send_message( + self, + session: ServerSession, + message: SessionMessage, + ) -> None: + """ + Send a message via the session. + + This is a helper for delivering queued task messages. + """ + await session.send_message(message) + + async def handle( + self, + request: GetTaskPayloadRequest, + session: ServerSession, + request_id: RequestId, + ) -> GetTaskPayloadResult: + """ + Handle a tasks/result request. + + This implements the dequeue-send-wait loop: + 1. Dequeue all pending messages + 2. Send each via transport with relatedRequestId = this request's ID + 3. If task not terminal, wait for status change + 4. Loop until task is terminal + 5. Return final result + + Args: + request: The GetTaskPayloadRequest + session: The server session for sending messages + request_id: The request ID for relatedRequestId routing + + Returns: + GetTaskPayloadResult with the task's final payload + """ + task_id = request.params.taskId + + while True: + task = await self._store.get_task(task_id) + if task is None: + raise McpError( + ErrorData( + code=INVALID_PARAMS, + message=f"Task not found: {task_id}", + ) + ) + + await self._deliver_queued_messages(task_id, session, request_id) + + # If task is terminal, return result + if is_terminal(task.status): + result = await self._store.get_result(task_id) + # GetTaskPayloadResult is a Result with extra="allow" + # The stored result contains the actual payload data + # Per spec: tasks/result MUST include _meta with related-task metadata + related_task = RelatedTaskMetadata(taskId=task_id) + related_task_meta: dict[str, Any] = {RELATED_TASK_METADATA_KEY: related_task.model_dump(by_alias=True)} + if result is not None: + result_data = result.model_dump(by_alias=True) + existing_meta: dict[str, Any] = result_data.get("_meta") or {} + result_data["_meta"] = {**existing_meta, **related_task_meta} + return GetTaskPayloadResult.model_validate(result_data) + return GetTaskPayloadResult.model_validate({"_meta": related_task_meta}) + + # Wait for task update (status change or new messages) + await self._wait_for_task_update(task_id) + + async def _deliver_queued_messages( + self, + task_id: str, + session: ServerSession, + request_id: RequestId, + ) -> None: + """ + Dequeue and send all pending messages for a task. + + Each message is sent via the session's write stream with + relatedRequestId set so responses route back to this stream. + """ + while True: + message = await self._queue.dequeue(task_id) + if message is None: + break + + # If this is a request (not notification), wait for response + if message.type == "request" and message.resolver is not None: + # Store the resolver so we can route the response back + original_id = message.original_request_id + if original_id is not None: + self._pending_requests[original_id] = message.resolver + + logger.debug("Delivering queued message for task %s: %s", task_id, message.type) + + # Send the message with relatedRequestId for routing + session_message = SessionMessage( + message=JSONRPCMessage(message.message), + metadata=ServerMessageMetadata(related_request_id=request_id), + ) + await self.send_message(session, session_message) + + async def _wait_for_task_update(self, task_id: str) -> None: + """ + Wait for task to be updated (status change or new message). + + Races between store update and queue message - first one wins. + """ + async with anyio.create_task_group() as tg: + + async def wait_for_store() -> None: + try: + await self._store.wait_for_update(task_id) + except Exception: + pass + finally: + tg.cancel_scope.cancel() + + async def wait_for_queue() -> None: + try: + await self._queue.wait_for_message(task_id) + except Exception: + pass + finally: + tg.cancel_scope.cancel() + + tg.start_soon(wait_for_store) + tg.start_soon(wait_for_queue) + + def route_response(self, request_id: RequestId, response: dict[str, Any]) -> bool: + """ + Route a response back to the waiting resolver. + + This is called when a response arrives for a queued request. + + Args: + request_id: The request ID from the response + response: The response data + + Returns: + True if response was routed, False if no pending request + """ + resolver = self._pending_requests.pop(request_id, None) + if resolver is not None and not resolver.done(): + resolver.set_result(response) + return True + return False + + def route_error(self, request_id: RequestId, error: ErrorData) -> bool: + """ + Route an error back to the waiting resolver. + + Args: + request_id: The request ID from the error response + error: The error data + + Returns: + True if error was routed, False if no pending request + """ + resolver = self._pending_requests.pop(request_id, None) + if resolver is not None and not resolver.done(): + resolver.set_exception(McpError(error)) + return True + return False diff --git a/src/mcp/server/experimental/task_support.py b/src/mcp/server/experimental/task_support.py new file mode 100644 index 0000000000..dbb2ed6d2b --- /dev/null +++ b/src/mcp/server/experimental/task_support.py @@ -0,0 +1,115 @@ +""" +TaskSupport - Configuration for experimental task support. + +This module provides the TaskSupport class which encapsulates all the +infrastructure needed for task-augmented requests: store, queue, and handler. +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field + +import anyio +from anyio.abc import TaskGroup + +from mcp.server.experimental.task_result_handler import TaskResultHandler +from mcp.server.session import ServerSession +from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore +from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, TaskMessageQueue +from mcp.shared.experimental.tasks.store import TaskStore + + +@dataclass +class TaskSupport: + """ + Configuration for experimental task support. + + Encapsulates the task store, message queue, result handler, and task group + for spawning background work. + + When enabled on a server, this automatically: + - Configures response routing for each session + - Provides default handlers for task operations + - Manages a task group for background task execution + + Example: + # Simple in-memory setup + server.experimental.enable_tasks() + + # Custom store/queue for distributed systems + server.experimental.enable_tasks( + store=RedisTaskStore(redis_url), + queue=RedisTaskMessageQueue(redis_url), + ) + """ + + store: TaskStore + queue: TaskMessageQueue + handler: TaskResultHandler = field(init=False) + _task_group: TaskGroup | None = field(init=False, default=None) + + def __post_init__(self) -> None: + """Create the result handler from store and queue.""" + self.handler = TaskResultHandler(self.store, self.queue) + + @property + def task_group(self) -> TaskGroup: + """Get the task group for spawning background work. + + Raises: + RuntimeError: If not within a run() context + """ + if self._task_group is None: + raise RuntimeError("TaskSupport not running. Ensure Server.run() is active.") + return self._task_group + + @asynccontextmanager + async def run(self) -> AsyncIterator[None]: + """ + Run the task support lifecycle. + + This creates a task group for spawning background task work. + Called automatically by Server.run(). + + Usage: + async with task_support.run(): + # Task group is now available + ... + """ + async with anyio.create_task_group() as tg: + self._task_group = tg + try: + yield + finally: + self._task_group = None + + def configure_session(self, session: ServerSession) -> None: + """ + Configure a session for task support. + + This registers the result handler as a response router so that + responses to queued requests (elicitation, sampling) are routed + back to the waiting resolvers. + + Called automatically by Server.run() for each new session. + + Args: + session: The session to configure + """ + session.add_response_router(self.handler) + + @classmethod + def in_memory(cls) -> "TaskSupport": + """ + Create in-memory task support. + + Suitable for development, testing, and single-process servers. + For distributed systems, provide custom store and queue implementations. + + Returns: + TaskSupport configured with in-memory store and queue + """ + return cls( + store=InMemoryTaskStore(), + queue=InMemoryTaskMessageQueue(), + ) diff --git a/src/mcp/server/fastmcp/__init__.py b/src/mcp/server/fastmcp/__init__.py index 84b052078a..a89902cfd7 100644 --- a/src/mcp/server/fastmcp/__init__.py +++ b/src/mcp/server/fastmcp/__init__.py @@ -2,8 +2,10 @@ from importlib.metadata import version +from mcp.types import Icon + from .server import Context, FastMCP -from .utilities.types import Image +from .utilities.types import Audio, Image __version__ = version("mcp") -__all__ = ["FastMCP", "Context", "Image"] +__all__ = ["FastMCP", "Context", "Image", "Audio", "Icon"] diff --git a/src/mcp/server/fastmcp/prompts/base.py b/src/mcp/server/fastmcp/prompts/base.py index 71c48724ea..48c65b57c5 100644 --- a/src/mcp/server/fastmcp/prompts/base.py +++ b/src/mcp/server/fastmcp/prompts/base.py @@ -1,25 +1,31 @@ """Base classes for FastMCP prompts.""" +from __future__ import annotations + import inspect -import json from collections.abc import Awaitable, Callable, Sequence -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal import pydantic_core from pydantic import BaseModel, Field, TypeAdapter, validate_call -from mcp.types import EmbeddedResource, ImageContent, TextContent +from mcp.server.fastmcp.utilities.context_injection import find_context_parameter, inject_context +from mcp.server.fastmcp.utilities.func_metadata import func_metadata +from mcp.types import ContentBlock, Icon, TextContent -CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource +if TYPE_CHECKING: + from mcp.server.fastmcp.server import Context + from mcp.server.session import ServerSessionT + from mcp.shared.context import LifespanContextT, RequestT class Message(BaseModel): """Base class for all prompt messages.""" role: Literal["user", "assistant"] - content: CONTENT_TYPES + content: ContentBlock - def __init__(self, content: str | CONTENT_TYPES, **kwargs: Any): + def __init__(self, content: str | ContentBlock, **kwargs: Any): if isinstance(content, str): content = TextContent(type="text", text=content) super().__init__(content=content, **kwargs) @@ -30,7 +36,7 @@ class UserMessage(Message): role: Literal["user", "assistant"] = "user" - def __init__(self, content: str | CONTENT_TYPES, **kwargs: Any): + def __init__(self, content: str | ContentBlock, **kwargs: Any): super().__init__(content=content, **kwargs) @@ -39,17 +45,13 @@ class AssistantMessage(Message): role: Literal["user", "assistant"] = "assistant" - def __init__(self, content: str | CONTENT_TYPES, **kwargs: Any): + def __init__(self, content: str | ContentBlock, **kwargs: Any): super().__init__(content=content, **kwargs) -message_validator = TypeAdapter[UserMessage | AssistantMessage]( - UserMessage | AssistantMessage -) +message_validator = TypeAdapter[UserMessage | AssistantMessage](UserMessage | AssistantMessage) -SyncPromptResult = ( - str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]] -) +SyncPromptResult = str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]] PromptResult = SyncPromptResult | Awaitable[SyncPromptResult] @@ -57,33 +59,31 @@ class PromptArgument(BaseModel): """An argument that can be passed to a prompt.""" name: str = Field(description="Name of the argument") - description: str | None = Field( - None, description="Description of what the argument does" - ) - required: bool = Field( - default=False, description="Whether the argument is required" - ) + description: str | None = Field(None, description="Description of what the argument does") + required: bool = Field(default=False, description="Whether the argument is required") class Prompt(BaseModel): """A prompt template that can be rendered with parameters.""" name: str = Field(description="Name of the prompt") - description: str | None = Field( - None, description="Description of what the prompt does" - ) - arguments: list[PromptArgument] | None = Field( - None, description="Arguments that can be passed to the prompt" - ) + title: str | None = Field(None, description="Human-readable title of the prompt") + description: str | None = Field(None, description="Description of what the prompt does") + arguments: list[PromptArgument] | None = Field(None, description="Arguments that can be passed to the prompt") fn: Callable[..., PromptResult | Awaitable[PromptResult]] = Field(exclude=True) + icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this prompt") + context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context", exclude=True) @classmethod def from_function( cls, fn: Callable[..., PromptResult | Awaitable[PromptResult]], name: str | None = None, + title: str | None = None, description: str | None = None, - ) -> "Prompt": + icons: list[Icon] | None = None, + context_kwarg: str | None = None, + ) -> Prompt: """Create a Prompt from a function. The function can return: @@ -94,15 +94,23 @@ def from_function( """ func_name = name or fn.__name__ - if func_name == "": + if func_name == "": # pragma: no cover raise ValueError("You must provide a name for lambda functions") - # Get schema from TypeAdapter - will fail if function isn't properly typed - parameters = TypeAdapter(fn).json_schema() + # Find context parameter if it exists + if context_kwarg is None: # pragma: no branch + context_kwarg = find_context_parameter(fn) + + # Get schema from func_metadata, excluding context parameter + func_arg_metadata = func_metadata( + fn, + skip_names=[context_kwarg] if context_kwarg is not None else [], + ) + parameters = func_arg_metadata.arg_model.model_json_schema() # Convert parameters to PromptArguments arguments: list[PromptArgument] = [] - if "properties" in parameters: + if "properties" in parameters: # pragma: no branch for param_name, param in parameters["properties"].items(): required = param_name in parameters.get("required", []) arguments.append( @@ -118,12 +126,19 @@ def from_function( return cls( name=func_name, + title=title, description=description or fn.__doc__ or "", arguments=arguments, fn=fn, + icons=icons, + context_kwarg=context_kwarg, ) - async def render(self, arguments: dict[str, Any] | None = None) -> list[Message]: + async def render( + self, + arguments: dict[str, Any] | None = None, + context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, + ) -> list[Message]: """Render the prompt with arguments.""" # Validate required arguments if self.arguments: @@ -134,8 +149,11 @@ async def render(self, arguments: dict[str, Any] | None = None) -> list[Message] raise ValueError(f"Missing required arguments: {missing}") try: + # Add context to arguments if needed + call_args = inject_context(self.fn, arguments or {}, context, self.context_kwarg) + # Call function and check if result is a coroutine - result = self.fn(**(arguments or {})) + result = self.fn(**call_args) if inspect.iscoroutine(result): result = await result @@ -154,14 +172,12 @@ async def render(self, arguments: dict[str, Any] | None = None) -> list[Message] elif isinstance(msg, str): content = TextContent(type="text", text=msg) messages.append(UserMessage(content=content)) - else: - content = json.dumps(pydantic_core.to_jsonable_python(msg)) + else: # pragma: no cover + content = pydantic_core.to_json(msg, fallback=str, indent=2).decode() messages.append(Message(role="user", content=content)) - except Exception: - raise ValueError( - f"Could not convert prompt result to message: {msg}" - ) + except Exception: # pragma: no cover + raise ValueError(f"Could not convert prompt result to message: {msg}") return messages - except Exception as e: + except Exception as e: # pragma: no cover raise ValueError(f"Error rendering prompt {self.name}: {e}") diff --git a/src/mcp/server/fastmcp/prompts/manager.py b/src/mcp/server/fastmcp/prompts/manager.py index 7ccbdef369..6d032c73a0 100644 --- a/src/mcp/server/fastmcp/prompts/manager.py +++ b/src/mcp/server/fastmcp/prompts/manager.py @@ -1,10 +1,17 @@ """Prompt management functionality.""" -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from mcp.server.fastmcp.prompts.base import Message, Prompt from mcp.server.fastmcp.utilities.logging import get_logger +if TYPE_CHECKING: + from mcp.server.fastmcp.server import Context + from mcp.server.session import ServerSessionT + from mcp.shared.context import LifespanContextT, RequestT + logger = get_logger(__name__) @@ -40,11 +47,14 @@ def add_prompt( return prompt async def render_prompt( - self, name: str, arguments: dict[str, Any] | None = None + self, + name: str, + arguments: dict[str, Any] | None = None, + context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, ) -> list[Message]: """Render a prompt by name with arguments.""" prompt = self.get_prompt(name) if not prompt: raise ValueError(f"Unknown prompt: {name}") - return await prompt.render(arguments) + return await prompt.render(arguments, context=context) diff --git a/src/mcp/server/fastmcp/prompts/prompt_manager.py b/src/mcp/server/fastmcp/prompts/prompt_manager.py deleted file mode 100644 index 389e89624e..0000000000 --- a/src/mcp/server/fastmcp/prompts/prompt_manager.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Prompt management functionality.""" - -from mcp.server.fastmcp.prompts.base import Prompt -from mcp.server.fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class PromptManager: - """Manages FastMCP prompts.""" - - def __init__(self, warn_on_duplicate_prompts: bool = True): - self._prompts: dict[str, Prompt] = {} - self.warn_on_duplicate_prompts = warn_on_duplicate_prompts - - def add_prompt(self, prompt: Prompt) -> Prompt: - """Add a prompt to the manager.""" - logger.debug(f"Adding prompt: {prompt.name}") - existing = self._prompts.get(prompt.name) - if existing: - if self.warn_on_duplicate_prompts: - logger.warning(f"Prompt already exists: {prompt.name}") - return existing - self._prompts[prompt.name] = prompt - return prompt - - def get_prompt(self, name: str) -> Prompt | None: - """Get prompt by name.""" - return self._prompts.get(name) - - def list_prompts(self) -> list[Prompt]: - """List all registered prompts.""" - return list(self._prompts.values()) diff --git a/src/mcp/server/fastmcp/resources/base.py b/src/mcp/server/fastmcp/resources/base.py index b2050e7f87..557775eab5 100644 --- a/src/mcp/server/fastmcp/resources/base.py +++ b/src/mcp/server/fastmcp/resources/base.py @@ -13,24 +13,25 @@ field_validator, ) +from mcp.types import Annotations, Icon + class Resource(BaseModel, abc.ABC): """Base class for all resources.""" model_config = ConfigDict(validate_default=True) - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field( - default=..., description="URI of the resource" - ) + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(default=..., description="URI of the resource") name: str | None = Field(description="Name of the resource", default=None) - description: str | None = Field( - description="Description of the resource", default=None - ) + title: str | None = Field(description="Human-readable title of the resource", default=None) + description: str | None = Field(description="Description of the resource", default=None) mime_type: str = Field( default="text/plain", description="MIME type of the resource content", - pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$", + pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+(;\s*[a-zA-Z0-9\-_.]+=[a-zA-Z0-9\-_.]+)*$", ) + icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this resource") + annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource") @field_validator("name", mode="before") @classmethod @@ -45,4 +46,4 @@ def set_default_name(cls, name: str | None, info: ValidationInfo) -> str: @abc.abstractmethod async def read(self) -> str | bytes: """Read the resource content.""" - pass + pass # pragma: no cover diff --git a/src/mcp/server/fastmcp/resources/resource_manager.py b/src/mcp/server/fastmcp/resources/resource_manager.py index d27e6ac126..2e7dc171bc 100644 --- a/src/mcp/server/fastmcp/resources/resource_manager.py +++ b/src/mcp/server/fastmcp/resources/resource_manager.py @@ -1,13 +1,21 @@ """Resource manager functionality.""" +from __future__ import annotations + from collections.abc import Callable -from typing import Any +from typing import TYPE_CHECKING, Any from pydantic import AnyUrl from mcp.server.fastmcp.resources.base import Resource from mcp.server.fastmcp.resources.templates import ResourceTemplate from mcp.server.fastmcp.utilities.logging import get_logger +from mcp.types import Annotations, Icon + +if TYPE_CHECKING: + from mcp.server.fastmcp.server import Context + from mcp.server.session import ServerSessionT + from mcp.shared.context import LifespanContextT, RequestT logger = get_logger(__name__) @@ -51,21 +59,31 @@ def add_template( fn: Callable[..., Any], uri_template: str, name: str | None = None, + title: str | None = None, description: str | None = None, mime_type: str | None = None, + icons: list[Icon] | None = None, + annotations: Annotations | None = None, ) -> ResourceTemplate: """Add a template from a function.""" template = ResourceTemplate.from_function( fn, uri_template=uri_template, name=name, + title=title, description=description, mime_type=mime_type, + icons=icons, + annotations=annotations, ) self._templates[template.uri_template] = template return template - async def get_resource(self, uri: AnyUrl | str) -> Resource | None: + async def get_resource( + self, + uri: AnyUrl | str, + context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, + ) -> Resource | None: """Get resource by URI, checking concrete resources first, then templates.""" uri_str = str(uri) logger.debug("Getting resource", extra={"uri": uri_str}) @@ -78,8 +96,8 @@ async def get_resource(self, uri: AnyUrl | str) -> Resource | None: for template in self._templates.values(): if params := template.matches(uri_str): try: - return await template.create_resource(uri_str, params) - except Exception as e: + return await template.create_resource(uri_str, params, context=context) + except Exception as e: # pragma: no cover raise ValueError(f"Error creating resource from template: {e}") raise ValueError(f"Unknown resource: {uri}") diff --git a/src/mcp/server/fastmcp/resources/templates.py b/src/mcp/server/fastmcp/resources/templates.py index a30b182533..a98d37f0ac 100644 --- a/src/mcp/server/fastmcp/resources/templates.py +++ b/src/mcp/server/fastmcp/resources/templates.py @@ -5,28 +5,34 @@ import inspect import re from collections.abc import Callable -from typing import Any +from typing import TYPE_CHECKING, Any -from pydantic import BaseModel, Field, TypeAdapter, validate_call +from pydantic import BaseModel, Field, validate_call from mcp.server.fastmcp.resources.types import FunctionResource, Resource +from mcp.server.fastmcp.utilities.context_injection import find_context_parameter, inject_context +from mcp.server.fastmcp.utilities.func_metadata import func_metadata +from mcp.types import Annotations, Icon + +if TYPE_CHECKING: + from mcp.server.fastmcp.server import Context + from mcp.server.session import ServerSessionT + from mcp.shared.context import LifespanContextT, RequestT class ResourceTemplate(BaseModel): """A template for dynamically creating resources.""" - uri_template: str = Field( - description="URI template with parameters (e.g. weather://{city}/current)" - ) + uri_template: str = Field(description="URI template with parameters (e.g. weather://{city}/current)") name: str = Field(description="Name of the resource") + title: str | None = Field(description="Human-readable title of the resource", default=None) description: str | None = Field(description="Description of what the resource does") - mime_type: str = Field( - default="text/plain", description="MIME type of the resource content" - ) + mime_type: str = Field(default="text/plain", description="MIME type of the resource content") + icons: list[Icon] | None = Field(default=None, description="Optional list of icons for the resource template") + annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource template") fn: Callable[..., Any] = Field(exclude=True) - parameters: dict[str, Any] = Field( - description="JSON schema for function parameters" - ) + parameters: dict[str, Any] = Field(description="JSON schema for function parameters") + context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context") @classmethod def from_function( @@ -34,16 +40,28 @@ def from_function( fn: Callable[..., Any], uri_template: str, name: str | None = None, + title: str | None = None, description: str | None = None, mime_type: str | None = None, + icons: list[Icon] | None = None, + annotations: Annotations | None = None, + context_kwarg: str | None = None, ) -> ResourceTemplate: """Create a template from a function.""" func_name = name or fn.__name__ if func_name == "": - raise ValueError("You must provide a name for lambda functions") + raise ValueError("You must provide a name for lambda functions") # pragma: no cover + + # Find context parameter if it exists + if context_kwarg is None: # pragma: no branch + context_kwarg = find_context_parameter(fn) - # Get schema from TypeAdapter - will fail if function isn't properly typed - parameters = TypeAdapter(fn).json_schema() + # Get schema from func_metadata, excluding context parameter + func_arg_metadata = func_metadata( + fn, + skip_names=[context_kwarg] if context_kwarg is not None else [], + ) + parameters = func_arg_metadata.arg_model.model_json_schema() # ensure the arguments are properly cast fn = validate_call(fn) @@ -51,10 +69,14 @@ def from_function( return cls( uri_template=uri_template, name=func_name, + title=title, description=description or fn.__doc__ or "", mime_type=mime_type or "text/plain", + icons=icons, + annotations=annotations, fn=fn, parameters=parameters, + context_kwarg=context_kwarg, ) def matches(self, uri: str) -> dict[str, Any] | None: @@ -66,9 +88,17 @@ def matches(self, uri: str) -> dict[str, Any] | None: return match.groupdict() return None - async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: + async def create_resource( + self, + uri: str, + params: dict[str, Any], + context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, + ) -> Resource: """Create a resource from the template with the given parameters.""" try: + # Add context to params if needed + params = inject_context(self.fn, params, context, self.context_kwarg) + # Call function and check if result is a coroutine result = self.fn(**params) if inspect.iscoroutine(result): @@ -77,8 +107,11 @@ async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: return FunctionResource( uri=uri, # type: ignore name=self.name, + title=self.title, description=self.description, mime_type=self.mime_type, + icons=self.icons, + annotations=self.annotations, fn=lambda: result, # Capture result in closure ) except Exception as e: diff --git a/src/mcp/server/fastmcp/resources/types.py b/src/mcp/server/fastmcp/resources/types.py index d9fe2de6c8..680e72dc09 100644 --- a/src/mcp/server/fastmcp/resources/types.py +++ b/src/mcp/server/fastmcp/resources/types.py @@ -9,11 +9,12 @@ import anyio import anyio.to_thread import httpx -import pydantic.json +import pydantic import pydantic_core -from pydantic import Field, ValidationInfo +from pydantic import AnyUrl, Field, ValidationInfo, validate_call from mcp.server.fastmcp.resources.base import Resource +from mcp.types import Annotations, Icon class TextResource(Resource): @@ -23,7 +24,7 @@ class TextResource(Resource): async def read(self) -> str: """Read the text content.""" - return self.text + return self.text # pragma: no cover class BinaryResource(Resource): @@ -33,7 +34,7 @@ class BinaryResource(Resource): async def read(self) -> bytes: """Read the binary content.""" - return self.data + return self.data # pragma: no cover class FunctionResource(Resource): @@ -54,23 +55,54 @@ class FunctionResource(Resource): async def read(self) -> str | bytes: """Read the resource by calling the wrapped function.""" try: - result = ( - await self.fn() if inspect.iscoroutinefunction(self.fn) else self.fn() - ) - if isinstance(result, Resource): + # Call the function first to see if it returns a coroutine + result = self.fn() + # If it's a coroutine, await it + if inspect.iscoroutine(result): + result = await result + + if isinstance(result, Resource): # pragma: no cover return await result.read() - if isinstance(result, bytes): + elif isinstance(result, bytes): return result - if isinstance(result, str): + elif isinstance(result, str): return result - try: - return json.dumps(pydantic_core.to_jsonable_python(result)) - except (TypeError, pydantic_core.PydanticSerializationError): - # If JSON serialization fails, try str() - return str(result) + else: + return pydantic_core.to_json(result, fallback=str, indent=2).decode() except Exception as e: raise ValueError(f"Error reading resource {self.uri}: {e}") + @classmethod + def from_function( + cls, + fn: Callable[..., Any], + uri: str, + name: str | None = None, + title: str | None = None, + description: str | None = None, + mime_type: str | None = None, + icons: list[Icon] | None = None, + annotations: Annotations | None = None, + ) -> "FunctionResource": + """Create a FunctionResource from a function.""" + func_name = name or fn.__name__ + if func_name == "": # pragma: no cover + raise ValueError("You must provide a name for lambda functions") + + # ensure the arguments are properly cast + fn = validate_call(fn) + + return cls( + uri=AnyUrl(uri), + name=func_name, + title=title, + description=description or fn.__doc__ or "", + mime_type=mime_type or "text/plain", + fn=fn, + icons=icons, + annotations=annotations, + ) + class FileResource(Resource): """A resource that reads from a file. @@ -90,7 +122,7 @@ class FileResource(Resource): @pydantic.field_validator("path") @classmethod - def validate_absolute_path(cls, path: Path) -> Path: + def validate_absolute_path(cls, path: Path) -> Path: # pragma: no cover """Ensure path is absolute.""" if not path.is_absolute(): raise ValueError("Path must be absolute") @@ -119,13 +151,11 @@ class HttpResource(Resource): """A resource that reads from an HTTP endpoint.""" url: str = Field(description="URL to fetch content from") - mime_type: str = Field( - default="application/json", description="MIME type of the resource content" - ) + mime_type: str = Field(default="application/json", description="MIME type of the resource content") async def read(self) -> str | bytes: """Read the HTTP content.""" - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient() as client: # pragma: no cover response = await client.get(self.url) response.raise_for_status() return response.text @@ -135,25 +165,19 @@ class DirectoryResource(Resource): """A resource that lists files in a directory.""" path: Path = Field(description="Path to the directory") - recursive: bool = Field( - default=False, description="Whether to list files recursively" - ) - pattern: str | None = Field( - default=None, description="Optional glob pattern to filter files" - ) - mime_type: str = Field( - default="application/json", description="MIME type of the resource content" - ) + recursive: bool = Field(default=False, description="Whether to list files recursively") + pattern: str | None = Field(default=None, description="Optional glob pattern to filter files") + mime_type: str = Field(default="application/json", description="MIME type of the resource content") @pydantic.field_validator("path") @classmethod - def validate_absolute_path(cls, path: Path) -> Path: + def validate_absolute_path(cls, path: Path) -> Path: # pragma: no cover """Ensure path is absolute.""" if not path.is_absolute(): raise ValueError("Path must be absolute") return path - def list_files(self) -> list[Path]: + def list_files(self) -> list[Path]: # pragma: no cover """List files in the directory.""" if not self.path.exists(): raise FileNotFoundError(f"Directory not found: {self.path}") @@ -162,20 +186,12 @@ def list_files(self) -> list[Path]: try: if self.pattern: - return ( - list(self.path.glob(self.pattern)) - if not self.recursive - else list(self.path.rglob(self.pattern)) - ) - return ( - list(self.path.glob("*")) - if not self.recursive - else list(self.path.rglob("*")) - ) + return list(self.path.glob(self.pattern)) if not self.recursive else list(self.path.rglob(self.pattern)) + return list(self.path.glob("*")) if not self.recursive else list(self.path.rglob("*")) except Exception as e: raise ValueError(f"Error listing directory {self.path}: {e}") - async def read(self) -> str: # Always returns JSON string + async def read(self) -> str: # Always returns JSON string # pragma: no cover """Read the directory listing.""" try: files = await anyio.to_thread.run_sync(self.list_files) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index f3bb2586a4..51e93b6776 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -3,31 +3,57 @@ from __future__ import annotations as _annotations import inspect -import json import re -from collections.abc import AsyncIterator, Callable, Iterable, Sequence -from contextlib import ( - AbstractAsyncContextManager, - asynccontextmanager, +from collections.abc import ( + AsyncIterator, + Awaitable, + Callable, + Collection, + Iterable, + Sequence, ) -from itertools import chain +from contextlib import AbstractAsyncContextManager, asynccontextmanager from typing import Any, Generic, Literal import anyio import pydantic_core -from pydantic import BaseModel, Field +from pydantic import BaseModel from pydantic.networks import AnyUrl from pydantic_settings import BaseSettings, SettingsConfigDict from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.authentication import AuthenticationMiddleware from starlette.requests import Request +from starlette.responses import Response from starlette.routing import Mount, Route +from starlette.types import Receive, Scope, Send +from mcp.server.auth.middleware.auth_context import AuthContextMiddleware +from mcp.server.auth.middleware.bearer_auth import ( + BearerAuthBackend, + RequireAuthMiddleware, +) +from mcp.server.auth.provider import ( + OAuthAuthorizationServerProvider, + ProviderTokenVerifier, + TokenVerifier, +) +from mcp.server.auth.settings import AuthSettings +from mcp.server.elicitation import ( + ElicitationResult, + ElicitSchemaModelT, + UrlElicitationResult, + elicit_with_validation, +) +from mcp.server.elicitation import ( + elicit_url as _elicit_url, +) from mcp.server.fastmcp.exceptions import ResourceError from mcp.server.fastmcp.prompts import Prompt, PromptManager from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager -from mcp.server.fastmcp.tools import ToolManager +from mcp.server.fastmcp.tools import Tool, ToolManager +from mcp.server.fastmcp.utilities.context_injection import find_context_parameter from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger -from mcp.server.fastmcp.utilities.types import Image from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.lowlevel.server import LifespanResultT from mcp.server.lowlevel.server import Server as MCPServer @@ -35,14 +61,11 @@ from mcp.server.session import ServerSession, ServerSessionT from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server -from mcp.shared.context import LifespanContextT, RequestContext -from mcp.types import ( - AnyFunction, - EmbeddedResource, - GetPromptResult, - ImageContent, - TextContent, -) +from mcp.server.streamable_http import EventStore +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared.context import LifespanContextT, RequestContext, RequestT +from mcp.types import Annotations, AnyFunction, ContentBlock, GetPromptResult, Icon, ToolAnnotations from mcp.types import Prompt as MCPPrompt from mcp.types import PromptArgument as MCPPromptArgument from mcp.types import Resource as MCPResource @@ -62,73 +85,155 @@ class Settings(BaseSettings, Generic[LifespanResultT]): model_config = SettingsConfigDict( env_prefix="FASTMCP_", env_file=".env", + env_nested_delimiter="__", + nested_model_default_partial_update=True, extra="ignore", ) # Server settings - debug: bool = False - log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" + debug: bool + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] # HTTP settings - host: str = "0.0.0.0" - port: int = 8000 - sse_path: str = "/sse" - message_path: str = "/messages/" + host: str + port: int + mount_path: str + sse_path: str + message_path: str + streamable_http_path: str + + # StreamableHTTP settings + json_response: bool + stateless_http: bool + """Define if the server should create a new transport per request.""" # resource settings - warn_on_duplicate_resources: bool = True + warn_on_duplicate_resources: bool # tool settings - warn_on_duplicate_tools: bool = True + warn_on_duplicate_tools: bool # prompt settings - warn_on_duplicate_prompts: bool = True + warn_on_duplicate_prompts: bool - dependencies: list[str] = Field( - default_factory=list, - description="List of dependencies to install in the server environment", - ) + # TODO(Marcelo): Investigate if this is used. If it is, it's probably a good idea to remove it. + dependencies: list[str] + """A list of dependencies to install in the server environment.""" - lifespan: ( - Callable[[FastMCP], AbstractAsyncContextManager[LifespanResultT]] | None - ) = Field(None, description="Lifespan context manager") + lifespan: Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None + """A async context manager that will be called when the server is started.""" + + auth: AuthSettings | None + + # Transport security settings (DNS rebinding protection) + transport_security: TransportSecuritySettings | None def lifespan_wrapper( - app: FastMCP, - lifespan: Callable[[FastMCP], AbstractAsyncContextManager[LifespanResultT]], -) -> Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[object]]: + app: FastMCP[LifespanResultT], + lifespan: Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]], +) -> Callable[[MCPServer[LifespanResultT, Request]], AbstractAsyncContextManager[LifespanResultT]]: @asynccontextmanager - async def wrap(s: MCPServer[LifespanResultT]) -> AsyncIterator[object]: + async def wrap( + _: MCPServer[LifespanResultT, Request], + ) -> AsyncIterator[LifespanResultT]: async with lifespan(app) as context: yield context return wrap -class FastMCP: - def __init__( - self, name: str | None = None, instructions: str | None = None, **settings: Any +class FastMCP(Generic[LifespanResultT]): + def __init__( # noqa: PLR0913 + self, + name: str | None = None, + instructions: str | None = None, + website_url: str | None = None, + icons: list[Icon] | None = None, + auth_server_provider: (OAuthAuthorizationServerProvider[Any, Any, Any] | None) = None, + token_verifier: TokenVerifier | None = None, + event_store: EventStore | None = None, + retry_interval: int | None = None, + *, + tools: list[Tool] | None = None, + debug: bool = False, + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", + host: str = "127.0.0.1", + port: int = 8000, + mount_path: str = "/", + sse_path: str = "/sse", + message_path: str = "/messages/", + streamable_http_path: str = "/mcp", + json_response: bool = False, + stateless_http: bool = False, + warn_on_duplicate_resources: bool = True, + warn_on_duplicate_tools: bool = True, + warn_on_duplicate_prompts: bool = True, + dependencies: Collection[str] = (), + lifespan: (Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None) = None, + auth: AuthSettings | None = None, + transport_security: TransportSecuritySettings | None = None, ): - self.settings = Settings(**settings) + # Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6) + if transport_security is None and host in ("127.0.0.1", "localhost", "::1"): + transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"], + allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"], + ) + + self.settings = Settings( + debug=debug, + log_level=log_level, + host=host, + port=port, + mount_path=mount_path, + sse_path=sse_path, + message_path=message_path, + streamable_http_path=streamable_http_path, + json_response=json_response, + stateless_http=stateless_http, + warn_on_duplicate_resources=warn_on_duplicate_resources, + warn_on_duplicate_tools=warn_on_duplicate_tools, + warn_on_duplicate_prompts=warn_on_duplicate_prompts, + dependencies=list(dependencies), + lifespan=lifespan, + auth=auth, + transport_security=transport_security, + ) self._mcp_server = MCPServer( name=name or "FastMCP", instructions=instructions, - lifespan=lifespan_wrapper(self, self.settings.lifespan) - if self.settings.lifespan - else default_lifespan, - ) - self._tool_manager = ToolManager( - warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools - ) - self._resource_manager = ResourceManager( - warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources - ) - self._prompt_manager = PromptManager( - warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts + website_url=website_url, + icons=icons, + # TODO(Marcelo): It seems there's a type mismatch between the lifespan type from an FastMCP and Server. + # We need to create a Lifespan type that is a generic on the server type, like Starlette does. + lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore ) + self._tool_manager = ToolManager(tools=tools, warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools) + self._resource_manager = ResourceManager(warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources) + self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts) + # Validate auth configuration + if self.settings.auth is not None: + if auth_server_provider and token_verifier: # pragma: no cover + raise ValueError("Cannot specify both auth_server_provider and token_verifier") + if not auth_server_provider and not token_verifier: # pragma: no cover + raise ValueError("Must specify either auth_server_provider or token_verifier when auth is enabled") + elif auth_server_provider or token_verifier: # pragma: no cover + raise ValueError("Cannot specify auth_server_provider or token_verifier without auth settings") + + self._auth_server_provider = auth_server_provider + self._token_verifier = token_verifier + + # Create token verifier from provider if needed (backwards compatibility) + if auth_server_provider and not token_verifier: # pragma: no cover + self._token_verifier = ProviderTokenVerifier(auth_server_provider) + self._event_store = event_store + self._retry_interval = retry_interval + self._custom_starlette_routes: list[Route] = [] self.dependencies = self.settings.dependencies + self._session_manager: StreamableHTTPSessionManager | None = None # Set up MCP protocol handlers self._setup_handlers() @@ -144,25 +249,63 @@ def name(self) -> str: def instructions(self) -> str | None: return self._mcp_server.instructions - def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None: + @property + def website_url(self) -> str | None: + return self._mcp_server.website_url + + @property + def icons(self) -> list[Icon] | None: + return self._mcp_server.icons + + @property + def session_manager(self) -> StreamableHTTPSessionManager: + """Get the StreamableHTTP session manager. + + This is exposed to enable advanced use cases like mounting multiple + FastMCP servers in a single FastAPI application. + + Raises: + RuntimeError: If called before streamable_http_app() has been called. + """ + if self._session_manager is None: # pragma: no cover + raise RuntimeError( + "Session manager can only be accessed after" + "calling streamable_http_app()." + "The session manager is created lazily" + "to avoid unnecessary initialization." + ) + return self._session_manager # pragma: no cover + + def run( + self, + transport: Literal["stdio", "sse", "streamable-http"] = "stdio", + mount_path: str | None = None, + ) -> None: """Run the FastMCP server. Note this is a synchronous function. Args: - transport: Transport protocol to use ("stdio" or "sse") + transport: Transport protocol to use ("stdio", "sse", or "streamable-http") + mount_path: Optional mount path for SSE transport """ - TRANSPORTS = Literal["stdio", "sse"] - if transport not in TRANSPORTS.__args__: # type: ignore + TRANSPORTS = Literal["stdio", "sse", "streamable-http"] + if transport not in TRANSPORTS.__args__: # type: ignore # pragma: no cover raise ValueError(f"Unknown transport: {transport}") - if transport == "stdio": - anyio.run(self.run_stdio_async) - else: # transport == "sse" - anyio.run(self.run_sse_async) + match transport: + case "stdio": + anyio.run(self.run_stdio_async) + case "sse": # pragma: no cover + anyio.run(lambda: self.run_sse_async(mount_path)) + case "streamable-http": # pragma: no cover + anyio.run(self.run_streamable_http_async) def _setup_handlers(self) -> None: """Set up core MCP protocol handlers.""" self._mcp_server.list_tools()(self.list_tools) - self._mcp_server.call_tool()(self.call_tool) + # Note: we disable the lowlevel server's input validation. + # FastMCP does ad hoc conversion of incoming data before validating - + # for now we preserve this for backwards compatibility. + self._mcp_server.call_tool(validate_input=False)(self.call_tool) self._mcp_server.list_resources()(self.list_resources) self._mcp_server.read_resource()(self.read_resource) self._mcp_server.list_prompts()(self.list_prompts) @@ -175,13 +318,18 @@ async def list_tools(self) -> list[MCPTool]: return [ MCPTool( name=info.name, + title=info.title, description=info.description, inputSchema=info.parameters, + outputSchema=info.output_schema, + annotations=info.annotations, + icons=info.icons, + _meta=info.meta, ) for info in tools ] - def get_context(self) -> Context[ServerSession, object]: + def get_context(self) -> Context[ServerSession, LifespanResultT, Request]: """ Returns a Context object. Note that the context will only be valid during a request; outside a request, most methods will error. @@ -192,14 +340,10 @@ def get_context(self) -> Context[ServerSession, object]: request_context = None return Context(request_context=request_context, fastmcp=self) - async def call_tool( - self, name: str, arguments: dict[str, Any] - ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: + async def call_tool(self, name: str, arguments: dict[str, Any]) -> Sequence[ContentBlock] | dict[str, Any]: """Call a tool by name with arguments.""" context = self.get_context() - result = await self._tool_manager.call_tool(name, arguments, context=context) - converted_result = _convert_to_content(result) - return converted_result + return await self._tool_manager.call_tool(name, arguments, context=context, convert_result=True) async def list_resources(self) -> list[MCPResource]: """List all available resources.""" @@ -209,8 +353,11 @@ async def list_resources(self) -> list[MCPResource]: MCPResource( uri=resource.uri, name=resource.name or "", + title=resource.title, description=resource.description, mimeType=resource.mime_type, + icons=resource.icons, + annotations=resource.annotations, ) for resource in resources ] @@ -221,7 +368,11 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]: MCPResourceTemplate( uriTemplate=template.uri_template, name=template.name, + title=template.title, description=template.description, + mimeType=template.mime_type, + icons=template.icons, + annotations=template.annotations, ) for template in templates ] @@ -229,22 +380,28 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]: async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContents]: """Read a resource by URI.""" - resource = await self._resource_manager.get_resource(uri) - if not resource: + context = self.get_context() + resource = await self._resource_manager.get_resource(uri, context=context) + if not resource: # pragma: no cover raise ResourceError(f"Unknown resource: {uri}") try: content = await resource.read() return [ReadResourceContents(content=content, mime_type=resource.mime_type)] - except Exception as e: - logger.error(f"Error reading resource {uri}: {e}") + except Exception as e: # pragma: no cover + logger.exception(f"Error reading resource {uri}") raise ResourceError(str(e)) def add_tool( self, fn: AnyFunction, name: str | None = None, + title: str | None = None, description: str | None = None, + annotations: ToolAnnotations | None = None, + icons: list[Icon] | None = None, + meta: dict[str, Any] | None = None, + structured_output: bool | None = None, ) -> None: """Add a tool to the server. @@ -254,12 +411,45 @@ def add_tool( Args: fn: The function to register as a tool name: Optional name for the tool (defaults to function name) + title: Optional human-readable title for the tool description: Optional description of what the tool does + annotations: Optional ToolAnnotations providing additional tool information + structured_output: Controls whether the tool's output is structured or unstructured + - If None, auto-detects based on the function's return type annotation + - If True, creates a structured tool (return type annotation permitting) + - If False, unconditionally creates an unstructured tool """ - self._tool_manager.add_tool(fn, name=name, description=description) + self._tool_manager.add_tool( + fn, + name=name, + title=title, + description=description, + annotations=annotations, + icons=icons, + meta=meta, + structured_output=structured_output, + ) + + def remove_tool(self, name: str) -> None: + """Remove a tool from the server by name. + + Args: + name: The name of the tool to remove + + Raises: + ToolError: If the tool does not exist + """ + self._tool_manager.remove_tool(name) def tool( - self, name: str | None = None, description: str | None = None + self, + name: str | None = None, + title: str | None = None, + description: str | None = None, + annotations: ToolAnnotations | None = None, + icons: list[Icon] | None = None, + meta: dict[str, Any] | None = None, + structured_output: bool | None = None, ) -> Callable[[AnyFunction], AnyFunction]: """Decorator to register a tool. @@ -269,7 +459,13 @@ def tool( Args: name: Optional name for the tool (defaults to function name) + title: Optional human-readable title for the tool description: Optional description of what the tool does + annotations: Optional ToolAnnotations providing additional tool information + structured_output: Controls whether the tool's output is structured or unstructured + - If None, auto-detects based on the function's return type annotation + - If True, creates a structured tool (return type annotation permitting) + - If False, unconditionally creates an unstructured tool Example: @server.tool() @@ -289,16 +485,42 @@ async def async_tool(x: int, context: Context) -> str: # Check if user passed function directly instead of calling decorator if callable(name): raise TypeError( - "The @tool decorator was used incorrectly. " - "Did you forget to call it? Use @tool() instead of @tool" + "The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool" ) def decorator(fn: AnyFunction) -> AnyFunction: - self.add_tool(fn, name=name, description=description) + self.add_tool( + fn, + name=name, + title=title, + description=description, + annotations=annotations, + icons=icons, + meta=meta, + structured_output=structured_output, + ) return fn return decorator + def completion(self): + """Decorator to register a completion handler. + + The completion handler receives: + - ref: PromptReference or ResourceTemplateReference + - argument: CompletionArgument with name and partial value + - context: Optional CompletionContext with previously resolved arguments + + Example: + @mcp.completion() + async def handle_completion(ref, argument, context): + if isinstance(ref, ResourceTemplateReference): + # Return completions based on ref, argument, and context + return Completion(values=["option1", "option2"]) + return None + """ + return self._mcp_server.completion() + def add_resource(self, resource: Resource) -> None: """Add a resource to the server. @@ -312,8 +534,11 @@ def resource( uri: str, *, name: str | None = None, + title: str | None = None, description: str | None = None, mime_type: str | None = None, + icons: list[Icon] | None = None, + annotations: Annotations | None = None, ) -> Callable[[AnyFunction], AnyFunction]: """Decorator to register a function as a resource. @@ -329,6 +554,7 @@ def resource( Args: uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}") name: Optional name for the resource + title: Optional human-readable title for the resource description: Optional description of the resource mime_type: Optional MIME type for the resource @@ -360,18 +586,23 @@ async def get_weather(city: str) -> str: def decorator(fn: AnyFunction) -> AnyFunction: # Check if this should be a template + sig = inspect.signature(fn) has_uri_params = "{" in uri and "}" in uri - has_func_params = bool(inspect.signature(fn).parameters) + has_func_params = bool(sig.parameters) if has_uri_params or has_func_params: - # Validate that URI params match function params + # Check for Context parameter to exclude from validation + context_param = find_context_parameter(fn) + + # Validate that URI params match function params (excluding context) uri_params = set(re.findall(r"{(\w+)}", uri)) - func_params = set(inspect.signature(fn).parameters.keys()) + # We need to remove the context_param from the resource function if + # there is any. + func_params = {p for p in sig.parameters.keys() if p != context_param} if uri_params != func_params: raise ValueError( - f"Mismatch between URI parameters {uri_params} " - f"and function parameters {func_params}" + f"Mismatch between URI parameters {uri_params} and function parameters {func_params}" ) # Register as template @@ -379,17 +610,23 @@ def decorator(fn: AnyFunction) -> AnyFunction: fn=fn, uri_template=uri, name=name, + title=title, description=description, - mime_type=mime_type or "text/plain", + mime_type=mime_type, + icons=icons, + annotations=annotations, ) else: # Register as regular resource - resource = FunctionResource( - uri=AnyUrl(uri), + resource = FunctionResource.from_function( + fn=fn, + uri=uri, name=name, + title=title, description=description, - mime_type=mime_type or "text/plain", - fn=fn, + mime_type=mime_type, + icons=icons, + annotations=annotations, ) self.add_resource(resource) return fn @@ -405,12 +642,17 @@ def add_prompt(self, prompt: Prompt) -> None: self._prompt_manager.add_prompt(prompt) def prompt( - self, name: str | None = None, description: str | None = None + self, + name: str | None = None, + title: str | None = None, + description: str | None = None, + icons: list[Icon] | None = None, ) -> Callable[[AnyFunction], AnyFunction]: """Decorator to register a prompt. Args: name: Optional name for the prompt (defaults to function name) + title: Optional human-readable title for the prompt description: Optional description of what the prompt does Example: @@ -448,12 +690,60 @@ async def analyze_file(path: str) -> list[Message]: ) def decorator(func: AnyFunction) -> AnyFunction: - prompt = Prompt.from_function(func, name=name, description=description) + prompt = Prompt.from_function(func, name=name, title=title, description=description, icons=icons) self.add_prompt(prompt) return func return decorator + def custom_route( + self, + path: str, + methods: list[str], + name: str | None = None, + include_in_schema: bool = True, + ): + """ + Decorator to register a custom HTTP route on the FastMCP server. + + Allows adding arbitrary HTTP endpoints outside the standard MCP protocol, + which can be useful for OAuth callbacks, health checks, or admin APIs. + The handler function must be an async function that accepts a Starlette + Request and returns a Response. + + Routes using this decorator will not require authorization. It is intended + for uses that are either a part of authorization flows or intended to be + public such as health check endpoints. + + Args: + path: URL path for the route (e.g., "/oauth/callback") + methods: List of HTTP methods to support (e.g., ["GET", "POST"]) + name: Optional name for the route (to reference this route with + Starlette's reverse URL lookup feature) + include_in_schema: Whether to include in OpenAPI schema, defaults to True + + Example: + @server.custom_route("/health", methods=["GET"]) + async def health_check(request: Request) -> Response: + return JSONResponse({"status": "ok"}) + """ + + def decorator( # pragma: no cover + func: Callable[[Request], Awaitable[Response]], + ) -> Callable[[Request], Awaitable[Response]]: + self._custom_starlette_routes.append( + Route( + path, + endpoint=func, + methods=methods, + name=name, + include_in_schema=include_in_schema, + ) + ) + return func + + return decorator # pragma: no cover + async def run_stdio_async(self) -> None: """Run the server using stdio transport.""" async with stdio_server() as (read_stream, write_stream): @@ -463,10 +753,26 @@ async def run_stdio_async(self) -> None: self._mcp_server.create_initialization_options(), ) - async def run_sse_async(self) -> None: + async def run_sse_async(self, mount_path: str | None = None) -> None: # pragma: no cover """Run the server using SSE transport.""" import uvicorn - starlette_app = self.sse_app() + + starlette_app = self.sse_app(mount_path) + + config = uvicorn.Config( + starlette_app, + host=self.settings.host, + port=self.settings.port, + log_level=self.settings.log_level.lower(), + ) + server = uvicorn.Server(config) + await server.serve() + + async def run_streamable_http_async(self) -> None: # pragma: no cover + """Run the server using StreamableHTTP transport.""" + import uvicorn + + starlette_app = self.streamable_http_app() config = uvicorn.Config( starlette_app, @@ -477,28 +783,259 @@ async def run_sse_async(self) -> None: server = uvicorn.Server(config) await server.serve() - def sse_app(self) -> Starlette: + def _normalize_path(self, mount_path: str, endpoint: str) -> str: + """ + Combine mount path and endpoint to return a normalized path. + + Args: + mount_path: The mount path (e.g. "/github" or "/") + endpoint: The endpoint path (e.g. "/messages/") + + Returns: + Normalized path (e.g. "/github/messages/") + """ + # Special case: root path + if mount_path == "/": + return endpoint + + # Remove trailing slash from mount path + if mount_path.endswith("/"): + mount_path = mount_path[:-1] + + # Ensure endpoint starts with slash + if not endpoint.startswith("/"): + endpoint = "/" + endpoint + + # Combine paths + return mount_path + endpoint + + def sse_app(self, mount_path: str | None = None) -> Starlette: """Return an instance of the SSE server app.""" - sse = SseServerTransport(self.settings.message_path) + from starlette.middleware import Middleware + from starlette.routing import Mount, Route + + # Update mount_path in settings if provided + if mount_path is not None: + self.settings.mount_path = mount_path + + # Create normalized endpoint considering the mount path + normalized_message_endpoint = self._normalize_path(self.settings.mount_path, self.settings.message_path) + + # Set up auth context and dependencies + + sse = SseServerTransport( + normalized_message_endpoint, + security_settings=self.settings.transport_security, + ) + + async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover + # Add client ID from auth context into request context if available - async def handle_sse(request: Request) -> None: async with sse.connect_sse( - request.scope, - request.receive, - request._send, # type: ignore[reportPrivateUsage] + scope, + receive, + send, ) as streams: await self._mcp_server.run( streams[0], streams[1], self._mcp_server.create_initialization_options(), ) + return Response() + + # Create routes + routes: list[Route | Mount] = [] + middleware: list[Middleware] = [] + required_scopes = [] + + # Set up auth if configured + if self.settings.auth: # pragma: no cover + required_scopes = self.settings.auth.required_scopes or [] + + # Add auth middleware if token verifier is available + if self._token_verifier: + middleware = [ + # extract auth info from request (but do not require it) + Middleware( + AuthenticationMiddleware, + backend=BearerAuthBackend(self._token_verifier), + ), + # Add the auth context middleware to store + # authenticated user in a contextvar + Middleware(AuthContextMiddleware), + ] + + # Add auth endpoints if auth server provider is configured + if self._auth_server_provider: + from mcp.server.auth.routes import create_auth_routes + + routes.extend( + create_auth_routes( + provider=self._auth_server_provider, + issuer_url=self.settings.auth.issuer_url, + service_documentation_url=self.settings.auth.service_documentation_url, + client_registration_options=self.settings.auth.client_registration_options, + revocation_options=self.settings.auth.revocation_options, + ) + ) + + # When auth is configured, require authentication + if self._token_verifier: # pragma: no cover + # Determine resource metadata URL + resource_metadata_url = None + if self.settings.auth and self.settings.auth.resource_server_url: + from mcp.server.auth.routes import build_resource_metadata_url + + # Build compliant metadata URL for WWW-Authenticate header + resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url) + + # Auth is enabled, wrap the endpoints with RequireAuthMiddleware + routes.append( + Route( + self.settings.sse_path, + endpoint=RequireAuthMiddleware(handle_sse, required_scopes, resource_metadata_url), + methods=["GET"], + ) + ) + routes.append( + Mount( + self.settings.message_path, + app=RequireAuthMiddleware(sse.handle_post_message, required_scopes, resource_metadata_url), + ) + ) + else: # pragma: no cover + # Auth is disabled, no need for RequireAuthMiddleware + # Since handle_sse is an ASGI app, we need to create a compatible endpoint + async def sse_endpoint(request: Request) -> Response: + # Convert the Starlette request to ASGI parameters + return await handle_sse(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage] + + routes.append( + Route( + self.settings.sse_path, + endpoint=sse_endpoint, + methods=["GET"], + ) + ) + routes.append( + Mount( + self.settings.message_path, + app=sse.handle_post_message, + ) + ) + # Add protected resource metadata endpoint if configured as RS + if self.settings.auth and self.settings.auth.resource_server_url: # pragma: no cover + from mcp.server.auth.routes import create_protected_resource_routes + + routes.extend( + create_protected_resource_routes( + resource_url=self.settings.auth.resource_server_url, + authorization_servers=[self.settings.auth.issuer_url], + scopes_supported=self.settings.auth.required_scopes, + ) + ) + + # mount these routes last, so they have the lowest route matching precedence + routes.extend(self._custom_starlette_routes) + + # Create Starlette app with routes and middleware + return Starlette(debug=self.settings.debug, routes=routes, middleware=middleware) + + def streamable_http_app(self) -> Starlette: + """Return an instance of the StreamableHTTP server app.""" + from starlette.middleware import Middleware + + # Create session manager on first call (lazy initialization) + if self._session_manager is None: # pragma: no branch + self._session_manager = StreamableHTTPSessionManager( + app=self._mcp_server, + event_store=self._event_store, + retry_interval=self._retry_interval, + json_response=self.settings.json_response, + stateless=self.settings.stateless_http, # Use the stateless setting + security_settings=self.settings.transport_security, + ) + + # Create the ASGI handler + streamable_http_app = StreamableHTTPASGIApp(self._session_manager) + + # Create routes + routes: list[Route | Mount] = [] + middleware: list[Middleware] = [] + required_scopes = [] + + # Set up auth if configured + if self.settings.auth: # pragma: no cover + required_scopes = self.settings.auth.required_scopes or [] + + # Add auth middleware if token verifier is available + if self._token_verifier: + middleware = [ + Middleware( + AuthenticationMiddleware, + backend=BearerAuthBackend(self._token_verifier), + ), + Middleware(AuthContextMiddleware), + ] + + # Add auth endpoints if auth server provider is configured + if self._auth_server_provider: + from mcp.server.auth.routes import create_auth_routes + + routes.extend( + create_auth_routes( + provider=self._auth_server_provider, + issuer_url=self.settings.auth.issuer_url, + service_documentation_url=self.settings.auth.service_documentation_url, + client_registration_options=self.settings.auth.client_registration_options, + revocation_options=self.settings.auth.revocation_options, + ) + ) + + # Set up routes with or without auth + if self._token_verifier: # pragma: no cover + # Determine resource metadata URL + resource_metadata_url = None + if self.settings.auth and self.settings.auth.resource_server_url: + from mcp.server.auth.routes import build_resource_metadata_url + + # Build compliant metadata URL for WWW-Authenticate header + resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url) + + routes.append( + Route( + self.settings.streamable_http_path, + endpoint=RequireAuthMiddleware(streamable_http_app, required_scopes, resource_metadata_url), + ) + ) + else: + # Auth is disabled, no wrapper needed + routes.append( + Route( + self.settings.streamable_http_path, + endpoint=streamable_http_app, + ) + ) + + # Add protected resource metadata endpoint if configured as RS + if self.settings.auth and self.settings.auth.resource_server_url: # pragma: no cover + from mcp.server.auth.routes import create_protected_resource_routes + + routes.extend( + create_protected_resource_routes( + resource_url=self.settings.auth.resource_server_url, + authorization_servers=[self.settings.auth.issuer_url], + scopes_supported=self.settings.auth.required_scopes, + ) + ) + + routes.extend(self._custom_starlette_routes) return Starlette( debug=self.settings.debug, - routes=[ - Route(self.settings.sse_path, endpoint=handle_sse), - Mount(self.settings.message_path, app=sse.handle_post_message), - ], + routes=routes, + middleware=middleware, + lifespan=lambda app: self.session_manager.run(), ) async def list_prompts(self) -> list[MCPPrompt]: @@ -507,6 +1044,7 @@ async def list_prompts(self) -> list[MCPPrompt]: return [ MCPPrompt( name=prompt.name, + title=prompt.title, description=prompt.description, arguments=[ MCPPromptArgument( @@ -516,49 +1054,42 @@ async def list_prompts(self) -> list[MCPPrompt]: ) for arg in (prompt.arguments or []) ], + icons=prompt.icons, ) for prompt in prompts ] - async def get_prompt( - self, name: str, arguments: dict[str, Any] | None = None - ) -> GetPromptResult: + async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult: """Get a prompt by name with arguments.""" try: - messages = await self._prompt_manager.render_prompt(name, arguments) + prompt = self._prompt_manager.get_prompt(name) + if not prompt: + raise ValueError(f"Unknown prompt: {name}") - return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages)) + messages = await prompt.render(arguments, context=self.get_context()) + + return GetPromptResult( + description=prompt.description, + messages=pydantic_core.to_jsonable_python(messages), + ) except Exception as e: - logger.error(f"Error getting prompt {name}: {e}") + logger.exception(f"Error getting prompt {name}") raise ValueError(str(e)) -def _convert_to_content( - result: Any, -) -> Sequence[TextContent | ImageContent | EmbeddedResource]: - """Convert a result to a sequence of content objects.""" - if result is None: - return [] - - if isinstance(result, TextContent | ImageContent | EmbeddedResource): - return [result] - - if isinstance(result, Image): - return [result.to_image_content()] - - if isinstance(result, list | tuple): - return list(chain.from_iterable(_convert_to_content(item) for item in result)) # type: ignore[reportUnknownVariableType] +class StreamableHTTPASGIApp: + """ + ASGI application for Streamable HTTP server transport. + """ - if not isinstance(result, str): - try: - result = json.dumps(pydantic_core.to_jsonable_python(result)) - except Exception: - result = str(result) + def __init__(self, session_manager: StreamableHTTPSessionManager): + self.session_manager = session_manager - return [TextContent(type="text", text=result)] + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: # pragma: no cover + await self.session_manager.handle_request(scope, receive, send) -class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]): +class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]): """Context object providing access to MCP capabilities. This provides a cleaner interface to MCP's RequestContext functionality. @@ -592,13 +1123,13 @@ def my_tool(x: int, ctx: Context) -> str: The context is optional - tools that don't need it can omit the parameter. """ - _request_context: RequestContext[ServerSessionT, LifespanContextT] | None + _request_context: RequestContext[ServerSessionT, LifespanContextT, RequestT] | None _fastmcp: FastMCP | None def __init__( self, *, - request_context: RequestContext[ServerSessionT, LifespanContextT] | None = None, + request_context: (RequestContext[ServerSessionT, LifespanContextT, RequestT] | None) = None, fastmcp: FastMCP | None = None, **kwargs: Any, ): @@ -609,38 +1140,37 @@ def __init__( @property def fastmcp(self) -> FastMCP: """Access to the FastMCP server.""" - if self._fastmcp is None: + if self._fastmcp is None: # pragma: no cover raise ValueError("Context is not available outside of a request") - return self._fastmcp + return self._fastmcp # pragma: no cover @property - def request_context(self) -> RequestContext[ServerSessionT, LifespanContextT]: + def request_context( + self, + ) -> RequestContext[ServerSessionT, LifespanContextT, RequestT]: """Access to the underlying request context.""" - if self._request_context is None: + if self._request_context is None: # pragma: no cover raise ValueError("Context is not available outside of a request") return self._request_context - async def report_progress( - self, progress: float, total: float | None = None - ) -> None: + async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: """Report progress for the current operation. Args: progress: Current progress value e.g. 24 total: Optional total value e.g. 100 + message: Optional message e.g. Starting render... """ + progress_token = self.request_context.meta.progressToken if self.request_context.meta else None - progress_token = ( - self.request_context.meta.progressToken - if self.request_context.meta - else None - ) - - if progress_token is None: + if progress_token is None: # pragma: no cover return await self.request_context.session.send_progress_notification( - progress_token=progress_token, progress=progress, total=total + progress_token=progress_token, + progress=progress, + total=total, + message=message, ) async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]: @@ -652,17 +1182,85 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent Returns: The resource content as either text or bytes """ - assert ( - self._fastmcp is not None - ), "Context is not available outside of a request" + assert self._fastmcp is not None, "Context is not available outside of a request" return await self._fastmcp.read_resource(uri) + async def elicit( + self, + message: str, + schema: type[ElicitSchemaModelT], + ) -> ElicitationResult[ElicitSchemaModelT]: + """Elicit information from the client/user. + + This method can be used to interactively ask for additional information from the + client within a tool's execution. The client might display the message to the + user and collect a response according to the provided schema. Or in case a + client is an agent, it might decide how to handle the elicitation -- either by asking + the user or automatically generating a response. + + Args: + schema: A Pydantic model class defining the expected response structure, according to the specification, + only primive types are allowed. + message: Optional message to present to the user. If not provided, will use + a default message based on the schema + + Returns: + An ElicitationResult containing the action taken and the data if accepted + + Note: + Check the result.action to determine if the user accepted, declined, or cancelled. + The result.data will only be populated if action is "accept" and validation succeeded. + """ + + return await elicit_with_validation( + session=self.request_context.session, + message=message, + schema=schema, + related_request_id=self.request_id, + ) + + async def elicit_url( + self, + message: str, + url: str, + elicitation_id: str, + ) -> UrlElicitationResult: + """Request URL mode elicitation from the client. + + This directs the user to an external URL for out-of-band interactions + that must not pass through the MCP client. Use this for: + - Collecting sensitive credentials (API keys, passwords) + - OAuth authorization flows with third-party services + - Payment and subscription flows + - Any interaction where data should not pass through the LLM context + + The response indicates whether the user consented to navigate to the URL. + The actual interaction happens out-of-band. When the elicitation completes, + call `self.session.send_elicit_complete(elicitation_id)` to notify the client. + + Args: + message: Human-readable explanation of why the interaction is needed + url: The URL the user should navigate to + elicitation_id: Unique identifier for tracking this elicitation + + Returns: + UrlElicitationResult indicating accept, decline, or cancel + """ + return await _elicit_url( + session=self.request_context.session, + message=message, + url=url, + elicitation_id=elicitation_id, + related_request_id=self.request_id, + ) + async def log( self, level: Literal["debug", "info", "warning", "error"], message: str, *, logger_name: str | None = None, + extra: dict[str, Any] | None = None, ) -> None: """Send a log message to the client. @@ -670,20 +1268,30 @@ async def log( level: Log level (debug, info, warning, error) message: Log message logger_name: Optional logger name - **extra: Additional structured data to include + extra: Optional dictionary with additional structured data to include """ + + if extra: + log_data = { + "message": message, + **extra, + } + else: + log_data = message + await self.request_context.session.send_log_message( - level=level, data=message, logger=logger_name + level=level, + data=log_data, + logger=logger_name, + related_request_id=self.request_id, ) @property def client_id(self) -> str | None: """Get the client ID if available.""" return ( - getattr(self.request_context.meta, "client_id", None) - if self.request_context.meta - else None - ) + getattr(self.request_context.meta, "client_id", None) if self.request_context.meta else None + ) # pragma: no cover @property def request_id(self) -> str: @@ -695,19 +1303,53 @@ def session(self): """Access to the underlying session for advanced usage.""" return self.request_context.session + async def close_sse_stream(self) -> None: + """Close the SSE stream to trigger client reconnection. + + This method closes the HTTP connection for the current request, triggering + client reconnection. Events continue to be stored in the event store and will + be replayed when the client reconnects with Last-Event-ID. + + Use this to implement polling behavior during long-running operations - + client will reconnect after the retry interval specified in the priming event. + + Note: + This is a no-op if not using StreamableHTTP transport with event_store. + The callback is only available when event_store is configured. + """ + if self._request_context and self._request_context.close_sse_stream: # pragma: no cover + await self._request_context.close_sse_stream() + + async def close_standalone_sse_stream(self) -> None: + """Close the standalone GET SSE stream to trigger client reconnection. + + This method closes the HTTP connection for the standalone GET stream used + for unsolicited server-to-client notifications. The client SHOULD reconnect + with Last-Event-ID to resume receiving notifications. + + Note: + This is a no-op if not using StreamableHTTP transport with event_store. + Currently, client reconnection for standalone GET streams is NOT + implemented - this is a known gap. + """ + if self._request_context and self._request_context.close_standalone_sse_stream: # pragma: no cover + await self._request_context.close_standalone_sse_stream() + # Convenience methods for common log levels - async def debug(self, message: str, **extra: Any) -> None: + async def debug(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None: """Send a debug log message.""" - await self.log("debug", message, **extra) + await self.log("debug", message, logger_name=logger_name, extra=extra) - async def info(self, message: str, **extra: Any) -> None: + async def info(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None: """Send an info log message.""" - await self.log("info", message, **extra) + await self.log("info", message, logger_name=logger_name, extra=extra) - async def warning(self, message: str, **extra: Any) -> None: + async def warning( + self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None + ) -> None: """Send a warning log message.""" - await self.log("warning", message, **extra) + await self.log("warning", message, logger_name=logger_name, extra=extra) - async def error(self, message: str, **extra: Any) -> None: + async def error(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None: """Send an error log message.""" - await self.log("error", message, **extra) + await self.log("error", message, logger_name=logger_name, extra=extra) diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py index 92a216f56c..1ae6d90d19 100644 --- a/src/mcp/server/fastmcp/tools/base.py +++ b/src/mcp/server/fastmcp/tools/base.py @@ -1,18 +1,24 @@ from __future__ import annotations as _annotations +import functools import inspect from collections.abc import Callable -from typing import TYPE_CHECKING, Any, get_origin +from functools import cached_property +from typing import TYPE_CHECKING, Any from pydantic import BaseModel, Field from mcp.server.fastmcp.exceptions import ToolError +from mcp.server.fastmcp.utilities.context_injection import find_context_parameter from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata +from mcp.shared.exceptions import UrlElicitationRequiredError +from mcp.shared.tool_name_validation import validate_and_warn_tool_name +from mcp.types import Icon, ToolAnnotations if TYPE_CHECKING: from mcp.server.fastmcp.server import Context from mcp.server.session import ServerSessionT - from mcp.shared.context import LifespanContextT + from mcp.shared.context import LifespanContextT, RequestT class Tool(BaseModel): @@ -20,75 +26,101 @@ class Tool(BaseModel): fn: Callable[..., Any] = Field(exclude=True) name: str = Field(description="Name of the tool") + title: str | None = Field(None, description="Human-readable title of the tool") description: str = Field(description="Description of what the tool does") parameters: dict[str, Any] = Field(description="JSON schema for tool parameters") fn_metadata: FuncMetadata = Field( - description="Metadata about the function including a pydantic model for tool" - " arguments" + description="Metadata about the function including a pydantic model for tool arguments" ) is_async: bool = Field(description="Whether the tool is async") - context_kwarg: str | None = Field( - None, description="Name of the kwarg that should receive context" - ) + context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context") + annotations: ToolAnnotations | None = Field(None, description="Optional annotations for the tool") + icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this tool") + meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this tool") + + @cached_property + def output_schema(self) -> dict[str, Any] | None: + return self.fn_metadata.output_schema @classmethod def from_function( cls, fn: Callable[..., Any], name: str | None = None, + title: str | None = None, description: str | None = None, context_kwarg: str | None = None, + annotations: ToolAnnotations | None = None, + icons: list[Icon] | None = None, + meta: dict[str, Any] | None = None, + structured_output: bool | None = None, ) -> Tool: """Create a Tool from a function.""" - from mcp.server.fastmcp import Context - func_name = name or fn.__name__ + validate_and_warn_tool_name(func_name) + if func_name == "": raise ValueError("You must provide a name for lambda functions") func_doc = description or fn.__doc__ or "" - is_async = inspect.iscoroutinefunction(fn) + is_async = _is_async_callable(fn) - if context_kwarg is None: - sig = inspect.signature(fn) - for param_name, param in sig.parameters.items(): - if get_origin(param.annotation) is not None: - continue - if issubclass(param.annotation, Context): - context_kwarg = param_name - break + if context_kwarg is None: # pragma: no branch + context_kwarg = find_context_parameter(fn) func_arg_metadata = func_metadata( fn, skip_names=[context_kwarg] if context_kwarg is not None else [], + structured_output=structured_output, ) - parameters = func_arg_metadata.arg_model.model_json_schema() + parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True) return cls( fn=fn, name=func_name, + title=title, description=func_doc, parameters=parameters, fn_metadata=func_arg_metadata, is_async=is_async, context_kwarg=context_kwarg, + annotations=annotations, + icons=icons, + meta=meta, ) async def run( self, arguments: dict[str, Any], - context: Context[ServerSessionT, LifespanContextT] | None = None, + context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, + convert_result: bool = False, ) -> Any: """Run the tool with arguments.""" try: - return await self.fn_metadata.call_fn_with_arg_validation( + result = await self.fn_metadata.call_fn_with_arg_validation( self.fn, self.is_async, arguments, - {self.context_kwarg: context} - if self.context_kwarg is not None - else None, + {self.context_kwarg: context} if self.context_kwarg is not None else None, ) + + if convert_result: + result = self.fn_metadata.convert_result(result) + + return result + except UrlElicitationRequiredError: + # Re-raise UrlElicitationRequiredError so it can be properly handled + # as an MCP error response with code -32042 + raise except Exception as e: raise ToolError(f"Error executing tool {self.name}: {e}") from e + + +def _is_async_callable(obj: Any) -> bool: + while isinstance(obj, functools.partial): # pragma: no cover + obj = obj.func + + return inspect.iscoroutinefunction(obj) or ( + callable(obj) and inspect.iscoroutinefunction(getattr(obj, "__call__", None)) + ) diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py index 4d6ac268fd..095753de69 100644 --- a/src/mcp/server/fastmcp/tools/tool_manager.py +++ b/src/mcp/server/fastmcp/tools/tool_manager.py @@ -6,7 +6,8 @@ from mcp.server.fastmcp.exceptions import ToolError from mcp.server.fastmcp.tools.base import Tool from mcp.server.fastmcp.utilities.logging import get_logger -from mcp.shared.context import LifespanContextT +from mcp.shared.context import LifespanContextT, RequestT +from mcp.types import Icon, ToolAnnotations if TYPE_CHECKING: from mcp.server.fastmcp.server import Context @@ -18,8 +19,19 @@ class ToolManager: """Manages FastMCP tools.""" - def __init__(self, warn_on_duplicate_tools: bool = True): + def __init__( + self, + warn_on_duplicate_tools: bool = True, + *, + tools: list[Tool] | None = None, + ): self._tools: dict[str, Tool] = {} + if tools is not None: + for tool in tools: + if warn_on_duplicate_tools and tool.name in self._tools: + logger.warning(f"Tool already exists: {tool.name}") + self._tools[tool.name] = tool + self.warn_on_duplicate_tools = warn_on_duplicate_tools def get_tool(self, name: str) -> Tool | None: @@ -34,10 +46,24 @@ def add_tool( self, fn: Callable[..., Any], name: str | None = None, + title: str | None = None, description: str | None = None, + annotations: ToolAnnotations | None = None, + icons: list[Icon] | None = None, + meta: dict[str, Any] | None = None, + structured_output: bool | None = None, ) -> Tool: """Add a tool to the server.""" - tool = Tool.from_function(fn, name=name, description=description) + tool = Tool.from_function( + fn, + name=name, + title=title, + description=description, + annotations=annotations, + icons=icons, + meta=meta, + structured_output=structured_output, + ) existing = self._tools.get(tool.name) if existing: if self.warn_on_duplicate_tools: @@ -46,15 +72,22 @@ def add_tool( self._tools[tool.name] = tool return tool + def remove_tool(self, name: str) -> None: + """Remove a tool by name.""" + if name not in self._tools: + raise ToolError(f"Unknown tool: {name}") + del self._tools[name] + async def call_tool( self, name: str, arguments: dict[str, Any], - context: Context[ServerSessionT, LifespanContextT] | None = None, + context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, + convert_result: bool = False, ) -> Any: """Call a tool by name with arguments.""" tool = self.get_tool(name) if not tool: raise ToolError(f"Unknown tool: {name}") - return await tool.run(arguments, context=context) + return await tool.run(arguments, context=context, convert_result=convert_result) diff --git a/src/mcp/server/fastmcp/utilities/context_injection.py b/src/mcp/server/fastmcp/utilities/context_injection.py new file mode 100644 index 0000000000..66d0cbaa0c --- /dev/null +++ b/src/mcp/server/fastmcp/utilities/context_injection.py @@ -0,0 +1,68 @@ +"""Context injection utilities for FastMCP.""" + +from __future__ import annotations + +import inspect +import typing +from collections.abc import Callable +from typing import Any + + +def find_context_parameter(fn: Callable[..., Any]) -> str | None: + """Find the parameter that should receive the Context object. + + Searches through the function's signature to find a parameter + with a Context type annotation. + + Args: + fn: The function to inspect + + Returns: + The name of the context parameter, or None if not found + """ + from mcp.server.fastmcp.server import Context + + # Get type hints to properly resolve string annotations + try: + hints = typing.get_type_hints(fn) + except Exception: + # If we can't resolve type hints, we can't find the context parameter + return None + + # Check each parameter's type hint + for param_name, annotation in hints.items(): + # Handle direct Context type + if inspect.isclass(annotation) and issubclass(annotation, Context): + return param_name + + # Handle generic types like Optional[Context] + origin = typing.get_origin(annotation) + if origin is not None: + args = typing.get_args(annotation) + for arg in args: + if inspect.isclass(arg) and issubclass(arg, Context): + return param_name + + return None + + +def inject_context( + fn: Callable[..., Any], + kwargs: dict[str, Any], + context: Any | None, + context_kwarg: str | None, +) -> dict[str, Any]: + """Inject context into function kwargs if needed. + + Args: + fn: The function that will be called + kwargs: The current keyword arguments + context: The context object to inject (if any) + context_kwarg: The name of the parameter to inject into + + Returns: + Updated kwargs with context injected if applicable + """ + if context_kwarg is not None and context is not None: + return {**kwargs, context_kwarg: context} + return kwargs diff --git a/src/mcp/server/fastmcp/utilities/func_metadata.py b/src/mcp/server/fastmcp/utilities/func_metadata.py index 374391325e..fa443d2fcb 100644 --- a/src/mcp/server/fastmcp/utilities/func_metadata.py +++ b/src/mcp/server/fastmcp/utilities/func_metadata.py @@ -1,23 +1,49 @@ import inspect import json from collections.abc import Awaitable, Callable, Sequence -from typing import ( - Annotated, - Any, - ForwardRef, +from itertools import chain +from types import GenericAlias +from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints + +import pydantic_core +from pydantic import ( + BaseModel, + ConfigDict, + Field, + RootModel, + WithJsonSchema, + create_model, ) - -from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, create_model -from pydantic._internal._typing_extra import eval_type_backport from pydantic.fields import FieldInfo -from pydantic_core import PydanticUndefined +from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind +from typing_extensions import is_typeddict +from typing_inspection.introspection import ( + UNKNOWN, + AnnotationSource, + ForbiddenQualifier, + inspect_annotation, + is_union_origin, +) from mcp.server.fastmcp.exceptions import InvalidSignature from mcp.server.fastmcp.utilities.logging import get_logger +from mcp.server.fastmcp.utilities.types import Audio, Image +from mcp.types import CallToolResult, ContentBlock, TextContent logger = get_logger(__name__) +class StrictJsonSchema(GenerateJsonSchema): + """A JSON schema generator that raises exceptions instead of emitting warnings. + + This is used to detect non-serializable types during schema generation. + """ + + def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None: + # Raise an exception instead of emitting a warning + raise ValueError(f"JSON schema warning: {kind} - {detail}") + + class ArgModelBase(BaseModel): """A model representing the arguments to a function.""" @@ -27,8 +53,11 @@ def model_dump_one_level(self) -> dict[str, Any]: That is, sub-models etc are not dumped - they are kept as pydantic models. """ kwargs: dict[str, Any] = {} - for field_name in self.__class__.model_fields.keys(): - kwargs[field_name] = getattr(self, field_name) + for field_name, field_info in self.__class__.model_fields.items(): + value = getattr(self, field_name) + # Use the alias if it exists, otherwise use the field name + output_name = field_info.alias if field_info.alias else field_name + kwargs[output_name] = value return kwargs model_config = ConfigDict( @@ -38,13 +67,13 @@ def model_dump_one_level(self) -> dict[str, Any]: class FuncMetadata(BaseModel): arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)] - # We can add things in the future like - # - Maybe some args are excluded from attempting to parse from JSON - # - Maybe some args are special (like context) for dependency injection + output_schema: dict[str, Any] | None = None + output_model: Annotated[type[BaseModel], WithJsonSchema(None)] | None = None + wrap_output: bool = False async def call_fn_with_arg_validation( self, - fn: Callable[..., Any] | Awaitable[Any], + fn: Callable[..., Any | Awaitable[Any]], fn_is_async: bool, arguments_to_validate: dict[str, Any], arguments_to_pass_directly: dict[str, Any] | None, @@ -61,12 +90,45 @@ async def call_fn_with_arg_validation( arguments_parsed_dict |= arguments_to_pass_directly or {} if fn_is_async: - if isinstance(fn, Awaitable): - return await fn return await fn(**arguments_parsed_dict) - if isinstance(fn, Callable): + else: return fn(**arguments_parsed_dict) - raise TypeError("fn must be either Callable or Awaitable") + + def convert_result(self, result: Any) -> Any: + """ + Convert the result of a function call to the appropriate format for + the lowlevel server tool call handler: + + - If output_model is None, return the unstructured content directly. + - If output_model is not None, convert the result to structured output format + (dict[str, Any]) and return both unstructured and structured content. + + Note: we return unstructured content here **even though the lowlevel server + tool call handler provides generic backwards compatibility serialization of + structured content**. This is for FastMCP backwards compatibility: we need to + retain FastMCP's ad hoc conversion logic for constructing unstructured output + from function return values, whereas the lowlevel server simply serializes + the structured output. + """ + if isinstance(result, CallToolResult): + if self.output_schema is not None: + assert self.output_model is not None, "Output model must be set if output schema is defined" + self.output_model.model_validate(result.structuredContent) + return result + + unstructured_content = _convert_to_content(result) + + if self.output_schema is None: + return unstructured_content + else: + if self.wrap_output: + result = {"result": result} + + assert self.output_model is not None, "Output model must be set if output schema is defined" + validated = self.output_model.model_validate(result) + structured_content = validated.model_dump(mode="json", by_alias=True) + + return (unstructured_content, structured_content) def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: """Pre-parse data from JSON. @@ -80,12 +142,23 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: dicts (JSON objects) as JSON strings, which can be pre-parsed here. """ new_data = data.copy() # Shallow copy - for field_name in self.arg_model.model_fields.keys(): - if field_name not in data.keys(): + + # Build a mapping from input keys (including aliases) to field info + key_to_field_info: dict[str, FieldInfo] = {} + for field_name, field_info in self.arg_model.model_fields.items(): + # Map both the field name and its alias (if any) to the field info + key_to_field_info[field_name] = field_info + if field_info.alias: + key_to_field_info[field_info.alias] = field_info + + for data_key, data_value in data.items(): + if data_key not in key_to_field_info: # pragma: no cover continue - if isinstance(data[field_name], str): + + field_info = key_to_field_info[data_key] + if isinstance(data_value, str) and field_info.annotation is not str: try: - pre_parsed = json.loads(data[field_name]) + pre_parsed = json.loads(data_value) except json.JSONDecodeError: continue # Not JSON - skip if isinstance(pre_parsed, str | int | float): @@ -93,7 +166,7 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: # Should really be parsed as '"hello"' in Python - but if we parse # it as JSON it'll turn into just 'hello'. So we skip it. continue - new_data[field_name] = pre_parsed + new_data[data_key] = pre_parsed assert new_data.keys() == data.keys() return new_data @@ -103,14 +176,16 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: def func_metadata( - func: Callable[..., Any], skip_names: Sequence[str] = () + func: Callable[..., Any], + skip_names: Sequence[str] = (), + structured_output: bool | None = None, ) -> FuncMetadata: """Given a function, return metadata including a pydantic model representing its signature. The use case for this is ``` - meta = func_to_pyd(func) + meta = func_metadata(func) validated_args = meta.arg_model.model_validate(some_raw_data_dict) return func(**validated_args.model_dump_one_level()) ``` @@ -122,93 +197,337 @@ def func_metadata( func: The function to convert to a pydantic model skip_names: A list of parameter names to skip. These will not be included in the model. + structured_output: Controls whether the tool's output is structured or unstructured + - If None, auto-detects based on the function's return type annotation + - If True, creates a structured tool (return type annotation permitting) + - If False, unconditionally creates an unstructured tool + + If structured, creates a Pydantic model for the function's result based on its annotation. + Supports various return types: + - BaseModel subclasses (used directly) + - Primitive types (str, int, float, bool, bytes, None) - wrapped in a + model with a 'result' field + - TypedDict - converted to a Pydantic model with same fields + - Dataclasses and other annotated classes - converted to Pydantic models + - Generic types (list, dict, Union, etc.) - wrapped in a model with a 'result' field + Returns: - A pydantic model representing the function's signature. + A FuncMetadata object containing: + - arg_model: A pydantic model representing the function's arguments + - output_model: A pydantic model for the return type if output is structured + - output_conversion: Records how function output should be converted before returning. """ - sig = _get_typed_signature(func) + try: + sig = inspect.signature(func, eval_str=True) + except NameError as e: # pragma: no cover + # This raise could perhaps be skipped, and we (FastMCP) just call + # model_rebuild right before using it 🤷 + raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e params = sig.parameters dynamic_pydantic_model_params: dict[str, Any] = {} - globalns = getattr(func, "__globals__", {}) for param in params.values(): - if param.name.startswith("_"): - raise InvalidSignature( - f"Parameter {param.name} of {func.__name__} cannot start with '_'" - ) + if param.name.startswith("_"): # pragma: no cover + raise InvalidSignature(f"Parameter {param.name} of {func.__name__} cannot start with '_'") if param.name in skip_names: continue - annotation = param.annotation - - # `x: None` / `x: None = None` - if annotation is None: - annotation = Annotated[ - None, - Field( - default=param.default - if param.default is not inspect.Parameter.empty - else PydanticUndefined - ), - ] - - # Untyped field - if annotation is inspect.Parameter.empty: - annotation = Annotated[ - Any, - Field(), - # 🤷 - WithJsonSchema({"title": param.name, "type": "string"}), - ] - - field_info = FieldInfo.from_annotated_attribute( - _get_typed_annotation(annotation, globalns), - param.default - if param.default is not inspect.Parameter.empty - else PydanticUndefined, - ) - dynamic_pydantic_model_params[param.name] = (field_info.annotation, field_info) - continue + + annotation = param.annotation if param.annotation is not inspect.Parameter.empty else Any + field_name = param.name + field_kwargs: dict[str, Any] = {} + field_metadata: list[Any] = [] + + if param.annotation is inspect.Parameter.empty: + field_metadata.append(WithJsonSchema({"title": param.name, "type": "string"})) + # Check if the parameter name conflicts with BaseModel attributes + # This is necessary because Pydantic warns about shadowing parent attributes + if hasattr(BaseModel, field_name) and callable(getattr(BaseModel, field_name)): + # Use an alias to avoid the shadowing warning + field_kwargs["alias"] = field_name + # Use a prefixed field name + field_name = f"field_{field_name}" + + if param.default is not inspect.Parameter.empty: + dynamic_pydantic_model_params[field_name] = ( + Annotated[(annotation, *field_metadata, Field(**field_kwargs))], + param.default, + ) + else: + dynamic_pydantic_model_params[field_name] = Annotated[(annotation, *field_metadata, Field(**field_kwargs))] arguments_model = create_model( f"{func.__name__}Arguments", - **dynamic_pydantic_model_params, __base__=ArgModelBase, + **dynamic_pydantic_model_params, ) - resp = FuncMetadata(arg_model=arguments_model) - return resp + if structured_output is False: + return FuncMetadata(arg_model=arguments_model) + + # set up structured output support based on return type annotation + + if sig.return_annotation is inspect.Parameter.empty and structured_output is True: + raise InvalidSignature(f"Function {func.__name__}: return annotation required for structured output") -def _get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any: - def try_eval_type( - value: Any, globalns: dict[str, Any], localns: dict[str, Any] - ) -> tuple[Any, bool]: + try: + inspected_return_ann = inspect_annotation(sig.return_annotation, annotation_source=AnnotationSource.FUNCTION) + except ForbiddenQualifier as e: + raise InvalidSignature(f"Function {func.__name__}: return annotation contains an invalid type qualifier") from e + + return_type_expr = inspected_return_ann.type + + # `AnnotationSource.FUNCTION` allows no type qualifier to be used, so `return_type_expr` is guaranteed to *not* be + # unknown (i.e. a bare `Final`). + assert return_type_expr is not UNKNOWN + + if is_union_origin(get_origin(return_type_expr)): + args = get_args(return_type_expr) + # Check if CallToolResult appears in the union (excluding None for Optional check) + if any(isinstance(arg, type) and issubclass(arg, CallToolResult) for arg in args if arg is not type(None)): + raise InvalidSignature( + f"Function {func.__name__}: CallToolResult cannot be used in Union or Optional types. " + "To return empty results, use: CallToolResult(content=[])" + ) + + original_annotation: Any + # if the typehint is CallToolResult, the user either intends to return without validation + # or they provided validation as Annotated metadata + if isinstance(return_type_expr, type) and issubclass(return_type_expr, CallToolResult): + if inspected_return_ann.metadata: + return_type_expr = inspected_return_ann.metadata[0] + if len(inspected_return_ann.metadata) >= 2: + # Reconstruct the original annotation, by preserving the remaining metadata, + # i.e. from `Annotated[CallToolResult, ReturnType, Gt(1)]` to + # `Annotated[ReturnType, Gt(1)]`: + original_annotation = Annotated[ + (return_type_expr, *inspected_return_ann.metadata[1:]) + ] # pragma: no cover + else: + # We only had `Annotated[CallToolResult, ReturnType]`, treat the original annotation + # as beging `ReturnType`: + original_annotation = return_type_expr + else: + return FuncMetadata(arg_model=arguments_model) + else: + original_annotation = sig.return_annotation + + output_model, output_schema, wrap_output = _try_create_model_and_schema( + original_annotation, return_type_expr, func.__name__ + ) + + if output_model is None and structured_output is True: + # Model creation failed or produced warnings - no structured output + raise InvalidSignature( + f"Function {func.__name__}: return type {return_type_expr} is not serializable for structured output" + ) + + return FuncMetadata( + arg_model=arguments_model, + output_schema=output_schema, + output_model=output_model, + wrap_output=wrap_output, + ) + + +def _try_create_model_and_schema( + original_annotation: Any, + type_expr: Any, + func_name: str, +) -> tuple[type[BaseModel] | None, dict[str, Any] | None, bool]: + """Try to create a model and schema for the given annotation without warnings. + + Args: + original_annotation: The original return annotation (may be wrapped in `Annotated`). + type_expr: The underlying type expression derived from the return annotation + (`Annotated` and type qualifiers were stripped). + func_name: The name of the function. + + Returns: + tuple of (model or None, schema or None, wrap_output) + Model and schema are None if warnings occur or creation fails. + wrap_output is True if the result needs to be wrapped in {"result": ...} + """ + model = None + wrap_output = False + + # First handle special case: None + if type_expr is None: + model = _create_wrapped_model(func_name, original_annotation) + wrap_output = True + + # Handle GenericAlias types (list[str], dict[str, int], Union[str, int], etc.) + elif isinstance(type_expr, GenericAlias): + origin = get_origin(type_expr) + + # Special case: dict with string keys can use RootModel + if origin is dict: + args = get_args(type_expr) + if len(args) == 2 and args[0] is str: + # TODO: should we use the original annotation? We are loosing any potential `Annotated` + # metadata for Pydantic here: + model = _create_dict_model(func_name, type_expr) + else: + # dict with non-str keys needs wrapping + model = _create_wrapped_model(func_name, original_annotation) + wrap_output = True + else: + # All other generic types need wrapping (list, tuple, Union, Optional, etc.) + model = _create_wrapped_model(func_name, original_annotation) + wrap_output = True + + # Handle regular type objects + elif isinstance(type_expr, type): + type_annotation = cast(type[Any], type_expr) + + # Case 1: BaseModel subclasses (can be used directly) + if issubclass(type_annotation, BaseModel): + model = type_annotation + + # Case 2: TypedDicts: + elif is_typeddict(type_annotation): + model = _create_model_from_typeddict(type_annotation) + + # Case 3: Primitive types that need wrapping + elif type_annotation in (str, int, float, bool, bytes, type(None)): + model = _create_wrapped_model(func_name, original_annotation) + wrap_output = True + + # Case 4: Other class types (dataclasses, regular classes with annotations) + else: + type_hints = get_type_hints(type_annotation) + if type_hints: + # Classes with type hints can be converted to Pydantic models + model = _create_model_from_class(type_annotation, type_hints) + # Classes without type hints are not serializable - model remains None + + # Handle any other types not covered above + else: + # This includes typing constructs that aren't GenericAlias in Python 3.10 + # (e.g., Union, Optional in some Python versions) + model = _create_wrapped_model(func_name, original_annotation) + wrap_output = True + + if model: + # If we successfully created a model, try to get its schema + # Use StrictJsonSchema to raise exceptions instead of warnings try: - return eval_type_backport(value, globalns, localns), True - except NameError: - return value, False + schema = model.model_json_schema(schema_generator=StrictJsonSchema) + except (TypeError, ValueError, pydantic_core.SchemaError, pydantic_core.ValidationError) as e: + # These are expected errors when a type can't be converted to a Pydantic schema + # TypeError: When Pydantic can't handle the type + # ValueError: When there are issues with the type definition (including our custom warnings) + # SchemaError: When Pydantic can't build a schema + # ValidationError: When validation fails + logger.info(f"Cannot create schema for type {type_expr} in {func_name}: {type(e).__name__}: {e}") + return None, None, False - if isinstance(annotation, str): - annotation = ForwardRef(annotation) - annotation, status = try_eval_type(annotation, globalns, globalns) + return model, schema, wrap_output - # This check and raise could perhaps be skipped, and we (FastMCP) just call - # model_rebuild right before using it 🤷 - if status is False: - raise InvalidSignature(f"Unable to evaluate type annotation {annotation}") - - return annotation - - -def _get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: - """Get function signature while evaluating forward references""" - signature = inspect.signature(call) - globalns = getattr(call, "__globals__", {}) - typed_params = [ - inspect.Parameter( - name=param.name, - kind=param.kind, - default=param.default, - annotation=_get_typed_annotation(param.annotation, globalns), + return None, None, False + + +_no_default = object() + + +def _create_model_from_class(cls: type[Any], type_hints: dict[str, Any]) -> type[BaseModel]: + """Create a Pydantic model from an ordinary class. + + The created model will: + - Have the same name as the class + - Have fields with the same names and types as the class's fields + - Include all fields whose type does not include None in the set of required fields + + Precondition: cls must have type hints (i.e., `type_hints` is non-empty) + """ + model_fields: dict[str, Any] = {} + for field_name, field_type in type_hints.items(): + if field_name.startswith("_"): # pragma: no cover + continue + + default = getattr(cls, field_name, _no_default) + if default is _no_default: + model_fields[field_name] = field_type + else: + model_fields[field_name] = (field_type, default) + + return create_model(cls.__name__, __config__=ConfigDict(from_attributes=True), **model_fields) + + +def _create_model_from_typeddict(td_type: type[Any]) -> type[BaseModel]: + """Create a Pydantic model from a TypedDict. + + The created model will have the same name and fields as the TypedDict. + """ + type_hints = get_type_hints(td_type) + required_keys = getattr(td_type, "__required_keys__", set(type_hints.keys())) + + model_fields: dict[str, Any] = {} + for field_name, field_type in type_hints.items(): + if field_name not in required_keys: + # For optional TypedDict fields, set default=None + # This makes them not required in the Pydantic model + # The model should use exclude_unset=True when dumping to get TypedDict semantics + model_fields[field_name] = (field_type, None) + else: + model_fields[field_name] = field_type + + return create_model(td_type.__name__, **model_fields) + + +def _create_wrapped_model(func_name: str, annotation: Any) -> type[BaseModel]: + """Create a model that wraps a type in a 'result' field. + + This is used for primitive types, generic types like list/dict, etc. + """ + model_name = f"{func_name}Output" + + return create_model(model_name, result=annotation) + + +def _create_dict_model(func_name: str, dict_annotation: Any) -> type[BaseModel]: + """Create a RootModel for dict[str, T] types.""" + + class DictModel(RootModel[dict_annotation]): + pass + + # Give it a meaningful name + DictModel.__name__ = f"{func_name}DictOutput" + DictModel.__qualname__ = f"{func_name}DictOutput" + + return DictModel + + +def _convert_to_content( + result: Any, +) -> Sequence[ContentBlock]: + """ + Convert a result to a sequence of content objects. + + Note: This conversion logic comes from previous versions of FastMCP and is being + retained for purposes of backwards compatibility. It produces different unstructured + output than the lowlevel server tool call handler, which just serializes structured + content verbatim. + """ + if result is None: # pragma: no cover + return [] + + if isinstance(result, ContentBlock): + return [result] + + if isinstance(result, Image): + return [result.to_image_content()] + + if isinstance(result, Audio): + return [result.to_audio_content()] + + if isinstance(result, list | tuple): + return list( + chain.from_iterable( + _convert_to_content(item) + for item in result # type: ignore + ) ) - for param in signature.parameters.values() - ] - typed_signature = inspect.Signature(typed_params) - return typed_signature + + if not isinstance(result, str): + result = pydantic_core.to_json(result, fallback=str, indent=2).decode() + + return [TextContent(type="text", text=result)] diff --git a/src/mcp/server/fastmcp/utilities/logging.py b/src/mcp/server/fastmcp/utilities/logging.py index 091d57e69d..4b47d3b882 100644 --- a/src/mcp/server/fastmcp/utilities/logging.py +++ b/src/mcp/server/fastmcp/utilities/logging.py @@ -25,15 +25,15 @@ def configure_logging( level: the log level to use """ handlers: list[logging.Handler] = [] - try: + try: # pragma: no cover from rich.console import Console from rich.logging import RichHandler handlers.append(RichHandler(console=Console(stderr=True), rich_tracebacks=True)) - except ImportError: + except ImportError: # pragma: no cover pass - if not handlers: + if not handlers: # pragma: no cover handlers.append(logging.StreamHandler()) logging.basicConfig( diff --git a/src/mcp/server/fastmcp/utilities/types.py b/src/mcp/server/fastmcp/utilities/types.py index ccaa3d69a2..d6928ca3f8 100644 --- a/src/mcp/server/fastmcp/utilities/types.py +++ b/src/mcp/server/fastmcp/utilities/types.py @@ -3,7 +3,7 @@ import base64 from pathlib import Path -from mcp.types import ImageContent +from mcp.types import AudioContent, ImageContent class Image: @@ -15,9 +15,9 @@ def __init__( data: bytes | None = None, format: str | None = None, ): - if path is None and data is None: + if path is None and data is None: # pragma: no cover raise ValueError("Either path or data must be provided") - if path is not None and data is not None: + if path is not None and data is not None: # pragma: no cover raise ValueError("Only one of path or data can be provided") self.path = Path(path) if path else None @@ -27,7 +27,7 @@ def __init__( def _get_mime_type(self) -> str: """Get MIME type from format or guess from file extension.""" - if self._format: + if self._format: # pragma: no cover return f"image/{self._format.lower()}" if self.path: @@ -39,16 +39,63 @@ def _get_mime_type(self) -> str: ".gif": "image/gif", ".webp": "image/webp", }.get(suffix, "application/octet-stream") - return "image/png" # default for raw binary data + return "image/png" # pragma: no cover # default for raw binary data def to_image_content(self) -> ImageContent: """Convert to MCP ImageContent.""" if self.path: with open(self.path, "rb") as f: data = base64.b64encode(f.read()).decode() - elif self.data is not None: + elif self.data is not None: # pragma: no cover data = base64.b64encode(self.data).decode() - else: + else: # pragma: no cover raise ValueError("No image data available") return ImageContent(type="image", data=data, mimeType=self._mime_type) + + +class Audio: + """Helper class for returning audio from tools.""" + + def __init__( + self, + path: str | Path | None = None, + data: bytes | None = None, + format: str | None = None, + ): + if not bool(path) ^ bool(data): # pragma: no cover + raise ValueError("Either path or data can be provided") + + self.path = Path(path) if path else None + self.data = data + self._format = format + self._mime_type = self._get_mime_type() + + def _get_mime_type(self) -> str: + """Get MIME type from format or guess from file extension.""" + if self._format: # pragma: no cover + return f"audio/{self._format.lower()}" + + if self.path: + suffix = self.path.suffix.lower() + return { + ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".flac": "audio/flac", + ".aac": "audio/aac", + ".m4a": "audio/mp4", + }.get(suffix, "application/octet-stream") + return "audio/wav" # pragma: no cover # default for raw binary data + + def to_audio_content(self) -> AudioContent: + """Convert to MCP AudioContent.""" + if self.path: + with open(self.path, "rb") as f: + data = base64.b64encode(f.read()).decode() + elif self.data is not None: # pragma: no cover + data = base64.b64encode(self.data).decode() + else: # pragma: no cover + raise ValueError("No audio data available") + + return AudioContent(type="audio", data=data, mimeType=self._mime_type) diff --git a/src/mcp/server/lowlevel/experimental.py b/src/mcp/server/lowlevel/experimental.py new file mode 100644 index 0000000000..0e6655b3de --- /dev/null +++ b/src/mcp/server/lowlevel/experimental.py @@ -0,0 +1,288 @@ +"""Experimental handlers for the low-level MCP server. + +WARNING: These APIs are experimental and may change without notice. +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING + +from mcp.server.experimental.task_support import TaskSupport +from mcp.server.lowlevel.func_inspection import create_call_wrapper +from mcp.shared.exceptions import McpError +from mcp.shared.experimental.tasks.helpers import cancel_task +from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore +from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, TaskMessageQueue +from mcp.shared.experimental.tasks.store import TaskStore +from mcp.types import ( + INVALID_PARAMS, + CancelTaskRequest, + CancelTaskResult, + ErrorData, + GetTaskPayloadRequest, + GetTaskPayloadResult, + GetTaskRequest, + GetTaskResult, + ListTasksRequest, + ListTasksResult, + ServerCapabilities, + ServerResult, + ServerTasksCapability, + ServerTasksRequestsCapability, + TasksCancelCapability, + TasksListCapability, + TasksToolsCapability, +) + +if TYPE_CHECKING: + from mcp.server.lowlevel.server import Server + +logger = logging.getLogger(__name__) + + +class ExperimentalHandlers: + """Experimental request/notification handlers. + + WARNING: These APIs are experimental and may change without notice. + """ + + def __init__( + self, + server: Server, + request_handlers: dict[type, Callable[..., Awaitable[ServerResult]]], + notification_handlers: dict[type, Callable[..., Awaitable[None]]], + ): + self._server = server + self._request_handlers = request_handlers + self._notification_handlers = notification_handlers + self._task_support: TaskSupport | None = None + + @property + def task_support(self) -> TaskSupport | None: + """Get the task support configuration, if enabled.""" + return self._task_support + + def update_capabilities(self, capabilities: ServerCapabilities) -> None: + # Only add tasks capability if handlers are registered + if not any( + req_type in self._request_handlers + for req_type in [GetTaskRequest, ListTasksRequest, CancelTaskRequest, GetTaskPayloadRequest] + ): + return + + capabilities.tasks = ServerTasksCapability() + if ListTasksRequest in self._request_handlers: + capabilities.tasks.list = TasksListCapability() + if CancelTaskRequest in self._request_handlers: + capabilities.tasks.cancel = TasksCancelCapability() + + capabilities.tasks.requests = ServerTasksRequestsCapability( + tools=TasksToolsCapability() + ) # assuming always supported for now + + def enable_tasks( + self, + store: TaskStore | None = None, + queue: TaskMessageQueue | None = None, + ) -> TaskSupport: + """ + Enable experimental task support. + + This sets up the task infrastructure and auto-registers default handlers + for tasks/get, tasks/result, tasks/list, and tasks/cancel. + + Args: + store: Custom TaskStore implementation (defaults to InMemoryTaskStore) + queue: Custom TaskMessageQueue implementation (defaults to InMemoryTaskMessageQueue) + + Returns: + The TaskSupport configuration object + + Example: + # Simple in-memory setup + server.experimental.enable_tasks() + + # Custom store/queue for distributed systems + server.experimental.enable_tasks( + store=RedisTaskStore(redis_url), + queue=RedisTaskMessageQueue(redis_url), + ) + + WARNING: This API is experimental and may change without notice. + """ + if store is None: + store = InMemoryTaskStore() + if queue is None: + queue = InMemoryTaskMessageQueue() + + self._task_support = TaskSupport(store=store, queue=queue) + + # Auto-register default handlers + self._register_default_task_handlers() + + return self._task_support + + def _register_default_task_handlers(self) -> None: + """Register default handlers for task operations.""" + assert self._task_support is not None + support = self._task_support + + # Register get_task handler if not already registered + if GetTaskRequest not in self._request_handlers: + + async def _default_get_task(req: GetTaskRequest) -> ServerResult: + task = await support.store.get_task(req.params.taskId) + if task is None: + raise McpError( + ErrorData( + code=INVALID_PARAMS, + message=f"Task not found: {req.params.taskId}", + ) + ) + return ServerResult( + GetTaskResult( + taskId=task.taskId, + status=task.status, + statusMessage=task.statusMessage, + createdAt=task.createdAt, + lastUpdatedAt=task.lastUpdatedAt, + ttl=task.ttl, + pollInterval=task.pollInterval, + ) + ) + + self._request_handlers[GetTaskRequest] = _default_get_task + + # Register get_task_result handler if not already registered + if GetTaskPayloadRequest not in self._request_handlers: + + async def _default_get_task_result(req: GetTaskPayloadRequest) -> ServerResult: + ctx = self._server.request_context + result = await support.handler.handle(req, ctx.session, ctx.request_id) + return ServerResult(result) + + self._request_handlers[GetTaskPayloadRequest] = _default_get_task_result + + # Register list_tasks handler if not already registered + if ListTasksRequest not in self._request_handlers: + + async def _default_list_tasks(req: ListTasksRequest) -> ServerResult: + cursor = req.params.cursor if req.params else None + tasks, next_cursor = await support.store.list_tasks(cursor) + return ServerResult(ListTasksResult(tasks=tasks, nextCursor=next_cursor)) + + self._request_handlers[ListTasksRequest] = _default_list_tasks + + # Register cancel_task handler if not already registered + if CancelTaskRequest not in self._request_handlers: + + async def _default_cancel_task(req: CancelTaskRequest) -> ServerResult: + result = await cancel_task(support.store, req.params.taskId) + return ServerResult(result) + + self._request_handlers[CancelTaskRequest] = _default_cancel_task + + def list_tasks( + self, + ) -> Callable[ + [Callable[[ListTasksRequest], Awaitable[ListTasksResult]]], + Callable[[ListTasksRequest], Awaitable[ListTasksResult]], + ]: + """Register a handler for listing tasks. + + WARNING: This API is experimental and may change without notice. + """ + + def decorator( + func: Callable[[ListTasksRequest], Awaitable[ListTasksResult]], + ) -> Callable[[ListTasksRequest], Awaitable[ListTasksResult]]: + logger.debug("Registering handler for ListTasksRequest") + wrapper = create_call_wrapper(func, ListTasksRequest) + + async def handler(req: ListTasksRequest) -> ServerResult: + result = await wrapper(req) + return ServerResult(result) + + self._request_handlers[ListTasksRequest] = handler + return func + + return decorator + + def get_task( + self, + ) -> Callable[ + [Callable[[GetTaskRequest], Awaitable[GetTaskResult]]], Callable[[GetTaskRequest], Awaitable[GetTaskResult]] + ]: + """Register a handler for getting task status. + + WARNING: This API is experimental and may change without notice. + """ + + def decorator( + func: Callable[[GetTaskRequest], Awaitable[GetTaskResult]], + ) -> Callable[[GetTaskRequest], Awaitable[GetTaskResult]]: + logger.debug("Registering handler for GetTaskRequest") + wrapper = create_call_wrapper(func, GetTaskRequest) + + async def handler(req: GetTaskRequest) -> ServerResult: + result = await wrapper(req) + return ServerResult(result) + + self._request_handlers[GetTaskRequest] = handler + return func + + return decorator + + def get_task_result( + self, + ) -> Callable[ + [Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]]], + Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]], + ]: + """Register a handler for getting task results/payload. + + WARNING: This API is experimental and may change without notice. + """ + + def decorator( + func: Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]], + ) -> Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]]: + logger.debug("Registering handler for GetTaskPayloadRequest") + wrapper = create_call_wrapper(func, GetTaskPayloadRequest) + + async def handler(req: GetTaskPayloadRequest) -> ServerResult: + result = await wrapper(req) + return ServerResult(result) + + self._request_handlers[GetTaskPayloadRequest] = handler + return func + + return decorator + + def cancel_task( + self, + ) -> Callable[ + [Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]]], + Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]], + ]: + """Register a handler for cancelling tasks. + + WARNING: This API is experimental and may change without notice. + """ + + def decorator( + func: Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]], + ) -> Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]]: + logger.debug("Registering handler for CancelTaskRequest") + wrapper = create_call_wrapper(func, CancelTaskRequest) + + async def handler(req: CancelTaskRequest) -> ServerResult: + result = await wrapper(req) + return ServerResult(result) + + self._request_handlers[CancelTaskRequest] = handler + return func + + return decorator diff --git a/src/mcp/server/lowlevel/func_inspection.py b/src/mcp/server/lowlevel/func_inspection.py new file mode 100644 index 0000000000..6231aa8954 --- /dev/null +++ b/src/mcp/server/lowlevel/func_inspection.py @@ -0,0 +1,54 @@ +import inspect +from collections.abc import Callable +from typing import Any, TypeVar, get_type_hints + +T = TypeVar("T") +R = TypeVar("R") + + +def create_call_wrapper(func: Callable[..., R], request_type: type[T]) -> Callable[[T], R]: + """ + Create a wrapper function that knows how to call func with the request object. + + Returns a wrapper function that takes the request and calls func appropriately. + + The wrapper handles three calling patterns: + 1. Positional-only parameter typed as request_type (no default): func(req) + 2. Positional/keyword parameter typed as request_type (no default): func(**{param_name: req}) + 3. No request parameter or parameter with default: func() + """ + try: + sig = inspect.signature(func) + type_hints = get_type_hints(func) + except (ValueError, TypeError, NameError): # pragma: no cover + return lambda _: func() + + # Check for positional-only parameter typed as request_type + for param_name, param in sig.parameters.items(): + if param.kind == inspect.Parameter.POSITIONAL_ONLY: + param_type = type_hints.get(param_name) + if param_type == request_type: # pragma: no branch + # Check if it has a default - if so, treat as old style + if param.default is not inspect.Parameter.empty: # pragma: no cover + return lambda _: func() + # Found positional-only parameter with correct type and no default + return lambda req: func(req) + + # Check for any positional/keyword parameter typed as request_type + for param_name, param in sig.parameters.items(): + if param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY): # pragma: no branch + param_type = type_hints.get(param_name) + if param_type == request_type: + # Check if it has a default - if so, treat as old style + if param.default is not inspect.Parameter.empty: # pragma: no cover + return lambda _: func() + + # Found keyword parameter with correct type and no default + # Need to capture param_name in closure properly + def make_keyword_wrapper(name: str) -> Callable[[Any], Any]: + return lambda req: func(**{name: req}) + + return make_keyword_wrapper(param_name) + + # No request parameter found - use old style + return lambda _: func() diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index dbaff30516..3fc2d497d1 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -11,7 +11,7 @@ 2. Define request handlers using decorators: @server.list_prompts() - async def handle_list_prompts() -> list[types.Prompt]: + async def handle_list_prompts(request: types.ListPromptsRequest) -> types.ListPromptsResult: # Implementation @server.get_prompt() @@ -21,7 +21,7 @@ async def handle_get_prompt( # Implementation @server.list_tools() - async def handle_list_tools() -> list[types.Tool]: + async def handle_list_tools(request: types.ListToolsRequest) -> types.ListToolsResult: # Implementation @server.call_tool() @@ -37,7 +37,8 @@ async def handle_list_resource_templates() -> list[types.ResourceTemplate]: 3. Define notification handlers if needed: @server.progress_notification() async def handle_progress( - progress_token: str | int, progress: float, total: float | None + progress_token: str | int, progress: float, total: float | None, + message: str | None ) -> None: # Implementation @@ -66,34 +67,46 @@ async def main(): from __future__ import annotations as _annotations +import base64 import contextvars +import json import logging import warnings from collections.abc import AsyncIterator, Awaitable, Callable, Iterable from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager -from typing import Any, Generic, TypeVar +from typing import Any, Generic, TypeAlias, cast import anyio +import jsonschema from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import AnyUrl +from typing_extensions import TypeVar import mcp.types as types +from mcp.server.experimental.request_context import Experimental +from mcp.server.lowlevel.experimental import ExperimentalHandlers +from mcp.server.lowlevel.func_inspection import create_call_wrapper from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession -from mcp.server.stdio import stdio_server as stdio_server from mcp.shared.context import RequestContext -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import McpError, UrlElicitationRequiredError +from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder +from mcp.shared.tool_name_validation import validate_and_warn_tool_name logger = logging.getLogger(__name__) -LifespanResultT = TypeVar("LifespanResultT") +LifespanResultT = TypeVar("LifespanResultT", default=Any) +RequestT = TypeVar("RequestT", default=Any) + +# type aliases for tool call results +StructuredContent: TypeAlias = dict[str, Any] +UnstructuredContent: TypeAlias = Iterable[types.ContentBlock] +CombinationContent: TypeAlias = tuple[UnstructuredContent, StructuredContent] # This will be properly typed in each Server instance's context -request_ctx: contextvars.ContextVar[RequestContext[ServerSession, Any]] = ( - contextvars.ContextVar("request_ctx") -) +request_ctx: contextvars.ContextVar[RequestContext[ServerSession, Any, Any]] = contextvars.ContextVar("request_ctx") class NotificationOptions: @@ -109,7 +122,7 @@ def __init__( @asynccontextmanager -async def lifespan(server: Server[LifespanResultT]) -> AsyncIterator[object]: +async def lifespan(_: Server[LifespanResultT, RequestT]) -> AsyncIterator[dict[str, Any]]: """Default lifespan context manager that does nothing. Args: @@ -121,28 +134,32 @@ async def lifespan(server: Server[LifespanResultT]) -> AsyncIterator[object]: yield {} -class Server(Generic[LifespanResultT]): +class Server(Generic[LifespanResultT, RequestT]): def __init__( self, name: str, version: str | None = None, instructions: str | None = None, + website_url: str | None = None, + icons: list[types.Icon] | None = None, lifespan: Callable[ - [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT] + [Server[LifespanResultT, RequestT]], + AbstractAsyncContextManager[LifespanResultT], ] = lifespan, ): self.name = name self.version = version self.instructions = instructions + self.website_url = website_url + self.icons = icons self.lifespan = lifespan - self.request_handlers: dict[ - type, Callable[..., Awaitable[types.ServerResult]] - ] = { + self.request_handlers: dict[type, Callable[..., Awaitable[types.ServerResult]]] = { types.PingRequest: _ping_handler, } self.notification_handlers: dict[type, Callable[..., Awaitable[None]]] = {} - self.notification_options = NotificationOptions() - logger.debug(f"Initializing server '{name}'") + self._tool_cache: dict[str, types.Tool] = {} + self._experimental_handlers: ExperimentalHandlers | None = None + logger.debug("Initializing server %r", name) def create_initialization_options( self, @@ -156,10 +173,10 @@ def pkg_version(package: str) -> str: from importlib.metadata import version return version(package) - except Exception: + except Exception: # pragma: no cover pass - return "unknown" + return "unknown" # pragma: no cover return InitializationOptions( server_name=self.name, @@ -169,6 +186,8 @@ def pkg_version(package: str) -> str: experimental_capabilities or {}, ), instructions=self.instructions, + website_url=self.website_url, + icons=self.icons, ) def get_capabilities( @@ -181,12 +200,11 @@ def get_capabilities( resources_capability = None tools_capability = None logging_capability = None + completions_capability = None # Set prompt capabilities if handler exists if types.ListPromptsRequest in self.request_handlers: - prompts_capability = types.PromptsCapability( - listChanged=notification_options.prompts_changed - ) + prompts_capability = types.PromptsCapability(listChanged=notification_options.prompts_changed) # Set resource capabilities if handler exists if types.ListResourcesRequest in self.request_handlers: @@ -196,34 +214,64 @@ def get_capabilities( # Set tool capabilities if handler exists if types.ListToolsRequest in self.request_handlers: - tools_capability = types.ToolsCapability( - listChanged=notification_options.tools_changed - ) + tools_capability = types.ToolsCapability(listChanged=notification_options.tools_changed) # Set logging capabilities if handler exists - if types.SetLevelRequest in self.request_handlers: + if types.SetLevelRequest in self.request_handlers: # pragma: no cover logging_capability = types.LoggingCapability() - return types.ServerCapabilities( + # Set completions capabilities if handler exists + if types.CompleteRequest in self.request_handlers: + completions_capability = types.CompletionsCapability() + + capabilities = types.ServerCapabilities( prompts=prompts_capability, resources=resources_capability, tools=tools_capability, logging=logging_capability, experimental=experimental_capabilities, + completions=completions_capability, ) + if self._experimental_handlers: + self._experimental_handlers.update_capabilities(capabilities) + return capabilities @property - def request_context(self) -> RequestContext[ServerSession, LifespanResultT]: + def request_context( + self, + ) -> RequestContext[ServerSession, LifespanResultT, RequestT]: """If called outside of a request context, this will raise a LookupError.""" return request_ctx.get() + @property + def experimental(self) -> ExperimentalHandlers: + """Experimental APIs for tasks and other features. + + WARNING: These APIs are experimental and may change without notice. + """ + + # We create this inline so we only add these capabilities _if_ they're actually used + if self._experimental_handlers is None: + self._experimental_handlers = ExperimentalHandlers(self, self.request_handlers, self.notification_handlers) + return self._experimental_handlers + def list_prompts(self): - def decorator(func: Callable[[], Awaitable[list[types.Prompt]]]): + def decorator( + func: Callable[[], Awaitable[list[types.Prompt]]] + | Callable[[types.ListPromptsRequest], Awaitable[types.ListPromptsResult]], + ): logger.debug("Registering handler for PromptListRequest") - async def handler(_: Any): - prompts = await func() - return types.ServerResult(types.ListPromptsResult(prompts=prompts)) + wrapper = create_call_wrapper(func, types.ListPromptsRequest) + + async def handler(req: types.ListPromptsRequest): + result = await wrapper(req) + # Handle both old style (list[Prompt]) and new style (ListPromptsResult) + if isinstance(result, types.ListPromptsResult): + return types.ServerResult(result) + else: + # Old style returns list[Prompt] + return types.ServerResult(types.ListPromptsResult(prompts=result)) self.request_handlers[types.ListPromptsRequest] = handler return func @@ -232,9 +280,7 @@ async def handler(_: Any): def get_prompt(self): def decorator( - func: Callable[ - [str, dict[str, str] | None], Awaitable[types.GetPromptResult] - ], + func: Callable[[str, dict[str, str] | None], Awaitable[types.GetPromptResult]], ): logger.debug("Registering handler for GetPromptRequest") @@ -248,14 +294,22 @@ async def handler(req: types.GetPromptRequest): return decorator def list_resources(self): - def decorator(func: Callable[[], Awaitable[list[types.Resource]]]): + def decorator( + func: Callable[[], Awaitable[list[types.Resource]]] + | Callable[[types.ListResourcesRequest], Awaitable[types.ListResourcesResult]], + ): logger.debug("Registering handler for ListResourcesRequest") - async def handler(_: Any): - resources = await func() - return types.ServerResult( - types.ListResourcesResult(resources=resources) - ) + wrapper = create_call_wrapper(func, types.ListResourcesRequest) + + async def handler(req: types.ListResourcesRequest): + result = await wrapper(req) + # Handle both old style (list[Resource]) and new style (ListResourcesResult) + if isinstance(result, types.ListResourcesResult): + return types.ServerResult(result) + else: + # Old style returns list[Resource] + return types.ServerResult(types.ListResourcesResult(resources=result)) self.request_handlers[types.ListResourcesRequest] = handler return func @@ -268,9 +322,7 @@ def decorator(func: Callable[[], Awaitable[list[types.ResourceTemplate]]]): async def handler(_: Any): templates = await func() - return types.ServerResult( - types.ListResourceTemplatesResult(resourceTemplates=templates) - ) + return types.ServerResult(types.ListResourceTemplatesResult(resourceTemplates=templates)) self.request_handlers[types.ListResourceTemplatesRequest] = handler return func @@ -279,9 +331,7 @@ async def handler(_: Any): def read_resource(self): def decorator( - func: Callable[ - [AnyUrl], Awaitable[str | bytes | Iterable[ReadResourceContents]] - ], + func: Callable[[AnyUrl], Awaitable[str | bytes | Iterable[ReadResourceContents]]], ): logger.debug("Registering handler for ReadResourceRequest") @@ -296,9 +346,7 @@ def create_content(data: str | bytes, mime_type: str | None): text=data, mimeType=mime_type or "text/plain", ) - case bytes() as data: - import base64 - + case bytes() as data: # pragma: no cover return types.BlobResourceContents( uri=req.params.uri, blob=base64.b64encode(data).decode(), @@ -306,7 +354,7 @@ def create_content(data: str | bytes, mime_type: str | None): ) match result: - case str() | bytes() as data: + case str() | bytes() as data: # pragma: no cover warnings.warn( "Returning str or bytes from read_resource is deprecated. " "Use Iterable[ReadResourceContents] instead.", @@ -316,20 +364,17 @@ def create_content(data: str | bytes, mime_type: str | None): content = create_content(data, None) case Iterable() as contents: contents_list = [ - create_content(content_item.content, content_item.mime_type) - for content_item in contents + create_content(content_item.content, content_item.mime_type) for content_item in contents ] return types.ServerResult( types.ReadResourceResult( contents=contents_list, ) ) - case _: - raise ValueError( - f"Unexpected return type from read_resource: {type(result)}" - ) + case _: # pragma: no cover + raise ValueError(f"Unexpected return type from read_resource: {type(result)}") - return types.ServerResult( + return types.ServerResult( # pragma: no cover types.ReadResourceResult( contents=[content], ) @@ -340,7 +385,7 @@ def create_content(data: str | bytes, mime_type: str | None): return decorator - def set_logging_level(self): + def set_logging_level(self): # pragma: no cover def decorator(func: Callable[[types.LoggingLevel], Awaitable[None]]): logger.debug("Registering handler for SetLevelRequest") @@ -353,7 +398,7 @@ async def handler(req: types.SetLevelRequest): return decorator - def subscribe_resource(self): + def subscribe_resource(self): # pragma: no cover def decorator(func: Callable[[AnyUrl], Awaitable[None]]): logger.debug("Registering handler for SubscribeRequest") @@ -366,7 +411,7 @@ async def handler(req: types.SubscribeRequest): return decorator - def unsubscribe_resource(self): + def unsubscribe_resource(self): # pragma: no cover def decorator(func: Callable[[AnyUrl], Awaitable[None]]): logger.debug("Registering handler for UnsubscribeRequest") @@ -380,26 +425,87 @@ async def handler(req: types.UnsubscribeRequest): return decorator def list_tools(self): - def decorator(func: Callable[[], Awaitable[list[types.Tool]]]): + def decorator( + func: Callable[[], Awaitable[list[types.Tool]]] + | Callable[[types.ListToolsRequest], Awaitable[types.ListToolsResult]], + ): logger.debug("Registering handler for ListToolsRequest") - async def handler(_: Any): - tools = await func() - return types.ServerResult(types.ListToolsResult(tools=tools)) + wrapper = create_call_wrapper(func, types.ListToolsRequest) + + async def handler(req: types.ListToolsRequest): + result = await wrapper(req) + + # Handle both old style (list[Tool]) and new style (ListToolsResult) + if isinstance(result, types.ListToolsResult): # pragma: no cover + # Refresh the tool cache with returned tools + for tool in result.tools: + validate_and_warn_tool_name(tool.name) + self._tool_cache[tool.name] = tool + return types.ServerResult(result) + else: + # Old style returns list[Tool] + # Clear and refresh the entire tool cache + self._tool_cache.clear() + for tool in result: + validate_and_warn_tool_name(tool.name) + self._tool_cache[tool.name] = tool + return types.ServerResult(types.ListToolsResult(tools=result)) self.request_handlers[types.ListToolsRequest] = handler return func return decorator - def call_tool(self): + def _make_error_result(self, error_message: str) -> types.ServerResult: + """Create a ServerResult with an error CallToolResult.""" + return types.ServerResult( + types.CallToolResult( + content=[types.TextContent(type="text", text=error_message)], + isError=True, + ) + ) + + async def _get_cached_tool_definition(self, tool_name: str) -> types.Tool | None: + """Get tool definition from cache, refreshing if necessary. + + Returns the Tool object if found, None otherwise. + """ + if tool_name not in self._tool_cache: + if types.ListToolsRequest in self.request_handlers: + logger.debug("Tool cache miss for %s, refreshing cache", tool_name) + await self.request_handlers[types.ListToolsRequest](None) + + tool = self._tool_cache.get(tool_name) + if tool is None: + logger.warning("Tool '%s' not listed, no validation will be performed", tool_name) + + return tool + + def call_tool(self, *, validate_input: bool = True): + """Register a tool call handler. + + Args: + validate_input: If True, validates input against inputSchema. Default is True. + + The handler validates input against inputSchema (if validate_input=True), calls the tool function, + and builds a CallToolResult with the results: + - Unstructured content (iterable of ContentBlock): returned in content + - Structured content (dict): returned in structuredContent, serialized JSON text returned in content + - Both: returned in content and structuredContent + + If outputSchema is defined, validates structuredContent or errors if missing. + """ + def decorator( func: Callable[ ..., Awaitable[ - Iterable[ - types.TextContent | types.ImageContent | types.EmbeddedResource - ] + UnstructuredContent + | StructuredContent + | CombinationContent + | types.CallToolResult + | types.CreateTaskResult ], ], ): @@ -407,17 +513,68 @@ def decorator( async def handler(req: types.CallToolRequest): try: - results = await func(req.params.name, (req.params.arguments or {})) - return types.ServerResult( - types.CallToolResult(content=list(results), isError=False) - ) - except Exception as e: + tool_name = req.params.name + arguments = req.params.arguments or {} + tool = await self._get_cached_tool_definition(tool_name) + + # input validation + if validate_input and tool: + try: + jsonschema.validate(instance=arguments, schema=tool.inputSchema) + except jsonschema.ValidationError as e: + return self._make_error_result(f"Input validation error: {e.message}") + + # tool call + results = await func(tool_name, arguments) + + # output normalization + unstructured_content: UnstructuredContent + maybe_structured_content: StructuredContent | None + if isinstance(results, types.CallToolResult): + return types.ServerResult(results) + elif isinstance(results, types.CreateTaskResult): + # Task-augmented execution returns task info instead of result + return types.ServerResult(results) + elif isinstance(results, tuple) and len(results) == 2: + # tool returned both structured and unstructured content + unstructured_content, maybe_structured_content = cast(CombinationContent, results) + elif isinstance(results, dict): + # tool returned structured content only + maybe_structured_content = cast(StructuredContent, results) + unstructured_content = [types.TextContent(type="text", text=json.dumps(results, indent=2))] + elif hasattr(results, "__iter__"): # pragma: no cover + # tool returned unstructured content only + unstructured_content = cast(UnstructuredContent, results) + maybe_structured_content = None + else: # pragma: no cover + return self._make_error_result(f"Unexpected return type from tool: {type(results).__name__}") + + # output validation + if tool and tool.outputSchema is not None: + if maybe_structured_content is None: + return self._make_error_result( + "Output validation error: outputSchema defined but no structured output returned" + ) + else: + try: + jsonschema.validate(instance=maybe_structured_content, schema=tool.outputSchema) + except jsonschema.ValidationError as e: + return self._make_error_result(f"Output validation error: {e.message}") + + # result return types.ServerResult( types.CallToolResult( - content=[types.TextContent(type="text", text=str(e))], - isError=True, + content=list(unstructured_content), + structuredContent=maybe_structured_content, + isError=False, ) ) + except UrlElicitationRequiredError: + # Re-raise UrlElicitationRequiredError so it can be properly handled + # by _handle_request, which converts it to an error response with code -32042 + raise + except Exception as e: + return self._make_error_result(str(e)) self.request_handlers[types.CallToolRequest] = handler return func @@ -426,13 +583,16 @@ async def handler(req: types.CallToolRequest): def progress_notification(self): def decorator( - func: Callable[[str | int, float, float | None], Awaitable[None]], + func: Callable[[str | int, float, float | None, str | None], Awaitable[None]], ): logger.debug("Registering handler for ProgressNotification") async def handler(req: types.ProgressNotification): await func( - req.params.progressToken, req.params.progress, req.params.total + req.params.progressToken, + req.params.progress, + req.params.total, + req.params.message, ) self.notification_handlers[types.ProgressNotification] = handler @@ -446,8 +606,9 @@ def completion(self): def decorator( func: Callable[ [ - types.PromptReference | types.ResourceReference, + types.PromptReference | types.ResourceTemplateReference, types.CompletionArgument, + types.CompletionContext | None, ], Awaitable[types.Completion | None], ], @@ -455,7 +616,7 @@ def decorator( logger.debug("Registering handler for CompleteRequest") async def handler(req: types.CompleteRequest): - completion = await func(req.params.ref, req.params.argument) + completion = await func(req.params.ref, req.params.argument, req.params.context) return types.ServerResult( types.CompleteResult( completion=completion @@ -471,24 +632,40 @@ async def handler(req: types.CompleteRequest): async def run( self, - read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception], - write_stream: MemoryObjectSendStream[types.JSONRPCMessage], + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], + write_stream: MemoryObjectSendStream[SessionMessage], initialization_options: InitializationOptions, # When False, exceptions are returned as messages to the client. # When True, exceptions are raised, which will cause the server to shut down # but also make tracing exceptions much easier during testing and when using # in-process servers. raise_exceptions: bool = False, + # When True, the server is stateless and + # clients can perform initialization with any node. The client must still follow + # the initialization lifecycle, but can do so with any available node + # rather than requiring initialization for each connection. + stateless: bool = False, ): async with AsyncExitStack() as stack: lifespan_context = await stack.enter_async_context(self.lifespan(self)) session = await stack.enter_async_context( - ServerSession(read_stream, write_stream, initialization_options) + ServerSession( + read_stream, + write_stream, + initialization_options, + stateless=stateless, + ) ) + # Configure task support for this session if enabled + task_support = self._experimental_handlers.task_support if self._experimental_handlers else None + if task_support is not None: + task_support.configure_session(session) + await stack.enter_async_context(task_support.run()) + async with anyio.create_task_group() as tg: async for message in session.incoming_messages: - logger.debug(f"Received message: {message}") + logger.debug("Received message: %s", message) tg.start_soon( self._handle_message, @@ -500,68 +677,102 @@ async def run( async def _handle_message( self, - message: RequestResponder[types.ClientRequest, types.ServerResult] - | types.ClientNotification - | Exception, + message: RequestResponder[types.ClientRequest, types.ServerResult] | types.ClientNotification | Exception, session: ServerSession, lifespan_context: LifespanResultT, raise_exceptions: bool = False, ): with warnings.catch_warnings(record=True) as w: - # TODO(Marcelo): We should be checking if message is Exception here. - match message: # type: ignore[reportMatchNotExhaustive] - case ( - RequestResponder(request=types.ClientRequest(root=req)) as responder - ): + match message: + case RequestResponder(request=types.ClientRequest(root=req)) as responder: with responder: - await self._handle_request( - message, req, session, lifespan_context, raise_exceptions - ) + await self._handle_request(message, req, session, lifespan_context, raise_exceptions) case types.ClientNotification(root=notify): await self._handle_notification(notify) + case Exception(): # pragma: no cover + logger.error(f"Received exception from stream: {message}") + await session.send_log_message( + level="error", + data="Internal Server Error", + logger="mcp.server.exception_handler", + ) + if raise_exceptions: + raise message - for warning in w: - logger.info(f"Warning: {warning.category.__name__}: {warning.message}") + for warning in w: # pragma: no cover + logger.info("Warning: %s: %s", warning.category.__name__, warning.message) async def _handle_request( self, message: RequestResponder[types.ClientRequest, types.ServerResult], - req: Any, + req: types.ClientRequestType, session: ServerSession, lifespan_context: LifespanResultT, raise_exceptions: bool, ): - logger.info(f"Processing request of type {type(req).__name__}") - if type(req) in self.request_handlers: - handler = self.request_handlers[type(req)] - logger.debug(f"Dispatching request of type {type(req).__name__}") + logger.info("Processing request of type %s", type(req).__name__) + + if handler := self.request_handlers.get(type(req)): + logger.debug("Dispatching request of type %s", type(req).__name__) token = None try: + # Extract request context and close_sse_stream from message metadata + request_data = None + close_sse_stream_cb = None + close_standalone_sse_stream_cb = None + if message.message_metadata is not None and isinstance( + message.message_metadata, ServerMessageMetadata + ): # pragma: no cover + request_data = message.message_metadata.request_context + close_sse_stream_cb = message.message_metadata.close_sse_stream + close_standalone_sse_stream_cb = message.message_metadata.close_standalone_sse_stream + # Set our global state that can be retrieved via # app.get_request_context() + client_capabilities = session.client_params.capabilities if session.client_params else None + task_support = self._experimental_handlers.task_support if self._experimental_handlers else None + # Get task metadata from request params if present + task_metadata = None + if hasattr(req, "params") and req.params is not None: + task_metadata = getattr(req.params, "task", None) token = request_ctx.set( RequestContext( message.request_id, message.request_meta, session, lifespan_context, + Experimental( + task_metadata=task_metadata, + _client_capabilities=client_capabilities, + _session=session, + _task_support=task_support, + ), + request=request_data, + close_sse_stream=close_sse_stream_cb, + close_standalone_sse_stream=close_standalone_sse_stream_cb, ) ) response = await handler(req) - except McpError as err: + except McpError as err: # pragma: no cover response = err.error - except Exception as err: + except anyio.get_cancelled_exc_class(): # pragma: no cover + logger.info( + "Request %s cancelled - duplicate response suppressed", + message.request_id, + ) + return + except Exception as err: # pragma: no cover if raise_exceptions: raise err response = types.ErrorData(code=0, message=str(err), data=None) finally: # Reset the global state after we are done - if token is not None: + if token is not None: # pragma: no branch request_ctx.reset(token) await message.respond(response) - else: + else: # pragma: no cover await message.respond( types.ErrorData( code=types.METHOD_NOT_FOUND, @@ -572,18 +783,13 @@ async def _handle_request( logger.debug("Response sent") async def _handle_notification(self, notify: Any): - if type(notify) in self.notification_handlers: - assert type(notify) in self.notification_handlers - - handler = self.notification_handlers[type(notify)] - logger.debug( - f"Dispatching notification of type " f"{type(notify).__name__}" - ) + if handler := self.notification_handlers.get(type(notify)): # type: ignore + logger.debug("Dispatching notification of type %s", type(notify).__name__) try: await handler(notify) - except Exception as err: - logger.error(f"Uncaught exception in notification handler: " f"{err}") + except Exception: # pragma: no cover + logger.exception("Uncaught exception in notification handler") async def _ping_handler(request: types.PingRequest) -> types.ServerResult: diff --git a/src/mcp/server/models.py b/src/mcp/server/models.py index 3b5abba785..ddf716cb95 100644 --- a/src/mcp/server/models.py +++ b/src/mcp/server/models.py @@ -6,6 +6,7 @@ from pydantic import BaseModel from mcp.types import ( + Icon, ServerCapabilities, ) @@ -15,3 +16,5 @@ class InitializationOptions(BaseModel): server_version: str capabilities: ServerCapabilities instructions: str | None = None + website_url: str | None = None + icons: list[Icon] | None = None diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 568ecd4b95..8f0baa3e9c 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -38,7 +38,7 @@ async def handle_list_prompts(ctx: RequestContext) -> list[types.Prompt]: """ from enum import Enum -from typing import Any, TypeVar +from typing import Any, TypeVar, overload import anyio import anyio.lowlevel @@ -46,11 +46,17 @@ async def handle_list_prompts(ctx: RequestContext) -> list[types.Prompt]: from pydantic import AnyUrl import mcp.types as types +from mcp.server.experimental.session_features import ExperimentalServerSessionFeatures from mcp.server.models import InitializationOptions +from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages +from mcp.shared.experimental.tasks.capabilities import check_tasks_capability +from mcp.shared.experimental.tasks.helpers import RELATED_TASK_METADATA_KEY +from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.session import ( BaseSession, RequestResponder, ) +from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS class InitializationState(Enum): @@ -62,9 +68,7 @@ class InitializationState(Enum): ServerSessionT = TypeVar("ServerSessionT", bound="ServerSession") ServerRequestResponder = ( - RequestResponder[types.ClientRequest, types.ServerResult] - | types.ClientNotification - | Exception + RequestResponder[types.ClientRequest, types.ServerResult] | types.ClientNotification | Exception ) @@ -79,41 +83,47 @@ class ServerSession( ): _initialized: InitializationState = InitializationState.NotInitialized _client_params: types.InitializeRequestParams | None = None + _experimental_features: ExperimentalServerSessionFeatures | None = None def __init__( self, - read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception], - write_stream: MemoryObjectSendStream[types.JSONRPCMessage], + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], + write_stream: MemoryObjectSendStream[SessionMessage], init_options: InitializationOptions, + stateless: bool = False, ) -> None: - super().__init__( - read_stream, write_stream, types.ClientRequest, types.ClientNotification + super().__init__(read_stream, write_stream, types.ClientRequest, types.ClientNotification) + self._initialization_state = ( + InitializationState.Initialized if stateless else InitializationState.NotInitialized ) - self._initialization_state = InitializationState.NotInitialized + self._init_options = init_options - self._incoming_message_stream_writer, self._incoming_message_stream_reader = ( - anyio.create_memory_object_stream[ServerRequestResponder](0) - ) - self._exit_stack.push_async_callback( - lambda: self._incoming_message_stream_reader.aclose() - ) - self._exit_stack.push_async_callback( - lambda: self._incoming_message_stream_writer.aclose() - ) + self._incoming_message_stream_writer, self._incoming_message_stream_reader = anyio.create_memory_object_stream[ + ServerRequestResponder + ](0) + self._exit_stack.push_async_callback(lambda: self._incoming_message_stream_reader.aclose()) @property def client_params(self) -> types.InitializeRequestParams | None: - return self._client_params + return self._client_params # pragma: no cover - def check_client_capability(self, capability: types.ClientCapabilities) -> bool: + @property + def experimental(self) -> ExperimentalServerSessionFeatures: + """Experimental APIs for server→client task operations. + + WARNING: These APIs are experimental and may change without notice. + """ + if self._experimental_features is None: + self._experimental_features = ExperimentalServerSessionFeatures(self) + return self._experimental_features + + def check_client_capability(self, capability: types.ClientCapabilities) -> bool: # pragma: no cover """Check if the client supports a specific capability.""" if self._client_params is None: return False - # Get client capabilities from initialization params client_caps = self._client_params.capabilities - # Check each specified capability in the passed in capability object if capability.roots is not None: if client_caps.roots is None: return False @@ -123,89 +133,107 @@ def check_client_capability(self, capability: types.ClientCapabilities) -> bool: if capability.sampling is not None: if client_caps.sampling is None: return False + if capability.sampling.context is not None and client_caps.sampling.context is None: + return False + if capability.sampling.tools is not None and client_caps.sampling.tools is None: + return False + + if capability.elicitation is not None and client_caps.elicitation is None: + return False if capability.experimental is not None: if client_caps.experimental is None: return False - # Check each experimental capability for exp_key, exp_value in capability.experimental.items(): - if ( - exp_key not in client_caps.experimental - or client_caps.experimental[exp_key] != exp_value - ): + if exp_key not in client_caps.experimental or client_caps.experimental[exp_key] != exp_value: return False + if capability.tasks is not None: + if client_caps.tasks is None: + return False + if not check_tasks_capability(capability.tasks, client_caps.tasks): + return False + return True - async def _received_request( - self, responder: RequestResponder[types.ClientRequest, types.ServerResult] - ): + async def _receive_loop(self) -> None: + async with self._incoming_message_stream_writer: + await super()._receive_loop() + + async def _received_request(self, responder: RequestResponder[types.ClientRequest, types.ServerResult]): match responder.request.root: case types.InitializeRequest(params=params): + requested_version = params.protocolVersion self._initialization_state = InitializationState.Initializing self._client_params = params with responder: await responder.respond( types.ServerResult( types.InitializeResult( - protocolVersion=types.LATEST_PROTOCOL_VERSION, + protocolVersion=requested_version + if requested_version in SUPPORTED_PROTOCOL_VERSIONS + else types.LATEST_PROTOCOL_VERSION, capabilities=self._init_options.capabilities, serverInfo=types.Implementation( name=self._init_options.server_name, version=self._init_options.server_version, + websiteUrl=self._init_options.website_url, + icons=self._init_options.icons, ), instructions=self._init_options.instructions, ) ) ) + self._initialization_state = InitializationState.Initialized + case types.PingRequest(): + # Ping requests are allowed at any time + pass case _: if self._initialization_state != InitializationState.Initialized: - raise RuntimeError( - "Received request before initialization was complete" - ) + raise RuntimeError("Received request before initialization was complete") - async def _received_notification( - self, notification: types.ClientNotification - ) -> None: + async def _received_notification(self, notification: types.ClientNotification) -> None: # Need this to avoid ASYNC910 await anyio.lowlevel.checkpoint() match notification.root: case types.InitializedNotification(): self._initialization_state = InitializationState.Initialized case _: - if self._initialization_state != InitializationState.Initialized: - raise RuntimeError( - "Received notification before initialization was complete" - ) + if self._initialization_state != InitializationState.Initialized: # pragma: no cover + raise RuntimeError("Received notification before initialization was complete") async def send_log_message( - self, level: types.LoggingLevel, data: Any, logger: str | None = None + self, + level: types.LoggingLevel, + data: Any, + logger: str | None = None, + related_request_id: types.RequestId | None = None, ) -> None: """Send a log message notification.""" await self.send_notification( types.ServerNotification( types.LoggingMessageNotification( - method="notifications/message", params=types.LoggingMessageNotificationParams( level=level, data=data, logger=logger, ), ) - ) + ), + related_request_id, ) - async def send_resource_updated(self, uri: AnyUrl) -> None: + async def send_resource_updated(self, uri: AnyUrl) -> None: # pragma: no cover """Send a resource updated notification.""" await self.send_notification( types.ServerNotification( types.ResourceUpdatedNotification( - method="notifications/resources/updated", params=types.ResourceUpdatedNotificationParams(uri=uri), ) ) ) + @overload async def create_message( self, messages: list[types.SamplingMessage], @@ -217,95 +245,441 @@ async def create_message( stop_sequences: list[str] | None = None, metadata: dict[str, Any] | None = None, model_preferences: types.ModelPreferences | None = None, + tools: None = None, + tool_choice: types.ToolChoice | None = None, + related_request_id: types.RequestId | None = None, ) -> types.CreateMessageResult: - """Send a sampling/create_message request.""" + """Overload: Without tools, returns single content.""" + ... + + @overload + async def create_message( + self, + messages: list[types.SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: types.IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: types.ModelPreferences | None = None, + tools: list[types.Tool], + tool_choice: types.ToolChoice | None = None, + related_request_id: types.RequestId | None = None, + ) -> types.CreateMessageResultWithTools: + """Overload: With tools, returns array-capable content.""" + ... + + async def create_message( + self, + messages: list[types.SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: types.IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: types.ModelPreferences | None = None, + tools: list[types.Tool] | None = None, + tool_choice: types.ToolChoice | None = None, + related_request_id: types.RequestId | None = None, + ) -> types.CreateMessageResult | types.CreateMessageResultWithTools: + """Send a sampling/create_message request. + + Args: + messages: The conversation messages to send. + max_tokens: Maximum number of tokens to generate. + system_prompt: Optional system prompt. + include_context: Optional context inclusion setting. + Should only be set to "thisServer" or "allServers" + if the client has sampling.context capability. + temperature: Optional sampling temperature. + stop_sequences: Optional stop sequences. + metadata: Optional metadata to pass through to the LLM provider. + model_preferences: Optional model selection preferences. + tools: Optional list of tools the LLM can use during sampling. + Requires client to have sampling.tools capability. + tool_choice: Optional control over tool usage behavior. + Requires client to have sampling.tools capability. + related_request_id: Optional ID of a related request. + + Returns: + The sampling result from the client. + + Raises: + McpError: If tools are provided but client doesn't support them. + ValueError: If tool_use or tool_result message structure is invalid. + """ + client_caps = self._client_params.capabilities if self._client_params else None + validate_sampling_tools(client_caps, tools, tool_choice) + validate_tool_use_result_messages(messages) + + request = types.ServerRequest( + types.CreateMessageRequest( + params=types.CreateMessageRequestParams( + messages=messages, + systemPrompt=system_prompt, + includeContext=include_context, + temperature=temperature, + maxTokens=max_tokens, + stopSequences=stop_sequences, + metadata=metadata, + modelPreferences=model_preferences, + tools=tools, + toolChoice=tool_choice, + ), + ) + ) + metadata_obj = ServerMessageMetadata(related_request_id=related_request_id) + + # Use different result types based on whether tools are provided + if tools is not None: + return await self.send_request( + request=request, + result_type=types.CreateMessageResultWithTools, + metadata=metadata_obj, + ) return await self.send_request( - types.ServerRequest( - types.CreateMessageRequest( - method="sampling/createMessage", - params=types.CreateMessageRequestParams( - messages=messages, - systemPrompt=system_prompt, - includeContext=include_context, - temperature=temperature, - maxTokens=max_tokens, - stopSequences=stop_sequences, - metadata=metadata, - modelPreferences=model_preferences, - ), - ) - ), - types.CreateMessageResult, + request=request, + result_type=types.CreateMessageResult, + metadata=metadata_obj, ) async def list_roots(self) -> types.ListRootsResult: """Send a roots/list request.""" + return await self.send_request( + types.ServerRequest(types.ListRootsRequest()), + types.ListRootsResult, + ) + + async def elicit( + self, + message: str, + requestedSchema: types.ElicitRequestedSchema, + related_request_id: types.RequestId | None = None, + ) -> types.ElicitResult: + """Send a form mode elicitation/create request. + + Args: + message: The message to present to the user + requestedSchema: Schema defining the expected response structure + related_request_id: Optional ID of the request that triggered this elicitation + + Returns: + The client's response + + Note: + This method is deprecated in favor of elicit_form(). It remains for + backward compatibility but new code should use elicit_form(). + """ + return await self.elicit_form(message, requestedSchema, related_request_id) + + async def elicit_form( + self, + message: str, + requestedSchema: types.ElicitRequestedSchema, + related_request_id: types.RequestId | None = None, + ) -> types.ElicitResult: + """Send a form mode elicitation/create request. + + Args: + message: The message to present to the user + requestedSchema: Schema defining the expected response structure + related_request_id: Optional ID of the request that triggered this elicitation + + Returns: + The client's response with form data + """ return await self.send_request( types.ServerRequest( - types.ListRootsRequest( - method="roots/list", + types.ElicitRequest( + params=types.ElicitRequestFormParams( + message=message, + requestedSchema=requestedSchema, + ), ) ), - types.ListRootsResult, + types.ElicitResult, + metadata=ServerMessageMetadata(related_request_id=related_request_id), ) - async def send_ping(self) -> types.EmptyResult: - """Send a ping request.""" + async def elicit_url( + self, + message: str, + url: str, + elicitation_id: str, + related_request_id: types.RequestId | None = None, + ) -> types.ElicitResult: + """Send a URL mode elicitation/create request. + + This directs the user to an external URL for out-of-band interactions + like OAuth flows, credential collection, or payment processing. + + Args: + message: Human-readable explanation of why the interaction is needed + url: The URL the user should navigate to + elicitation_id: Unique identifier for tracking this elicitation + related_request_id: Optional ID of the request that triggered this elicitation + + Returns: + The client's response indicating acceptance, decline, or cancellation + """ return await self.send_request( types.ServerRequest( - types.PingRequest( - method="ping", + types.ElicitRequest( + params=types.ElicitRequestURLParams( + message=message, + url=url, + elicitationId=elicitation_id, + ), ) ), + types.ElicitResult, + metadata=ServerMessageMetadata(related_request_id=related_request_id), + ) + + async def send_ping(self) -> types.EmptyResult: # pragma: no cover + """Send a ping request.""" + return await self.send_request( + types.ServerRequest(types.PingRequest()), types.EmptyResult, ) async def send_progress_notification( - self, progress_token: str | int, progress: float, total: float | None = None + self, + progress_token: str | int, + progress: float, + total: float | None = None, + message: str | None = None, + related_request_id: str | None = None, ) -> None: """Send a progress notification.""" await self.send_notification( types.ServerNotification( types.ProgressNotification( - method="notifications/progress", params=types.ProgressNotificationParams( progressToken=progress_token, progress=progress, total=total, + message=message, ), ) - ) + ), + related_request_id, ) - async def send_resource_list_changed(self) -> None: + async def send_resource_list_changed(self) -> None: # pragma: no cover """Send a resource list changed notification.""" - await self.send_notification( - types.ServerNotification( - types.ResourceListChangedNotification( - method="notifications/resources/list_changed", - ) - ) - ) + await self.send_notification(types.ServerNotification(types.ResourceListChangedNotification())) - async def send_tool_list_changed(self) -> None: + async def send_tool_list_changed(self) -> None: # pragma: no cover """Send a tool list changed notification.""" - await self.send_notification( - types.ServerNotification( - types.ToolListChangedNotification( - method="notifications/tools/list_changed", - ) - ) - ) + await self.send_notification(types.ServerNotification(types.ToolListChangedNotification())) - async def send_prompt_list_changed(self) -> None: + async def send_prompt_list_changed(self) -> None: # pragma: no cover """Send a prompt list changed notification.""" + await self.send_notification(types.ServerNotification(types.PromptListChangedNotification())) + + async def send_elicit_complete( + self, + elicitation_id: str, + related_request_id: types.RequestId | None = None, + ) -> None: + """Send an elicitation completion notification. + + This should be sent when a URL mode elicitation has been completed + out-of-band to inform the client that it may retry any requests + that were waiting for this elicitation. + + Args: + elicitation_id: The unique identifier of the completed elicitation + related_request_id: Optional ID of the request that triggered this + """ await self.send_notification( types.ServerNotification( - types.PromptListChangedNotification( - method="notifications/prompts/list_changed", + types.ElicitCompleteNotification( + params=types.ElicitCompleteNotificationParams(elicitationId=elicitation_id) ) - ) + ), + related_request_id, + ) + + def _build_elicit_form_request( + self, + message: str, + requestedSchema: types.ElicitRequestedSchema, + related_task_id: str | None = None, + task: types.TaskMetadata | None = None, + ) -> types.JSONRPCRequest: + """Build a form mode elicitation request without sending it. + + Args: + message: The message to present to the user + requestedSchema: Schema defining the expected response structure + related_task_id: If provided, adds io.modelcontextprotocol/related-task metadata + task: If provided, makes this a task-augmented request + + Returns: + A JSONRPCRequest ready to be sent or queued + """ + params = types.ElicitRequestFormParams( + message=message, + requestedSchema=requestedSchema, + task=task, + ) + params_data = params.model_dump(by_alias=True, mode="json", exclude_none=True) + + # Add related-task metadata if associated with a parent task + if related_task_id is not None: + # Defensive: model_dump() never includes _meta, but guard against future changes + if "_meta" not in params_data: # pragma: no cover + params_data["_meta"] = {} + params_data["_meta"][RELATED_TASK_METADATA_KEY] = types.RelatedTaskMetadata( + taskId=related_task_id + ).model_dump(by_alias=True) + + request_id = f"task-{related_task_id}-{id(params)}" if related_task_id else self._request_id + if related_task_id is None: + self._request_id += 1 + + return types.JSONRPCRequest( + jsonrpc="2.0", + id=request_id, + method="elicitation/create", + params=params_data, + ) + + def _build_elicit_url_request( + self, + message: str, + url: str, + elicitation_id: str, + related_task_id: str | None = None, + ) -> types.JSONRPCRequest: + """Build a URL mode elicitation request without sending it. + + Args: + message: Human-readable explanation of why the interaction is needed + url: The URL the user should navigate to + elicitation_id: Unique identifier for tracking this elicitation + related_task_id: If provided, adds io.modelcontextprotocol/related-task metadata + + Returns: + A JSONRPCRequest ready to be sent or queued + """ + params = types.ElicitRequestURLParams( + message=message, + url=url, + elicitationId=elicitation_id, + ) + params_data = params.model_dump(by_alias=True, mode="json", exclude_none=True) + + # Add related-task metadata if associated with a parent task + if related_task_id is not None: + # Defensive: model_dump() never includes _meta, but guard against future changes + if "_meta" not in params_data: # pragma: no cover + params_data["_meta"] = {} + params_data["_meta"][RELATED_TASK_METADATA_KEY] = types.RelatedTaskMetadata( + taskId=related_task_id + ).model_dump(by_alias=True) + + request_id = f"task-{related_task_id}-{id(params)}" if related_task_id else self._request_id + if related_task_id is None: + self._request_id += 1 + + return types.JSONRPCRequest( + jsonrpc="2.0", + id=request_id, + method="elicitation/create", + params=params_data, + ) + + def _build_create_message_request( + self, + messages: list[types.SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: types.IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: types.ModelPreferences | None = None, + tools: list[types.Tool] | None = None, + tool_choice: types.ToolChoice | None = None, + related_task_id: str | None = None, + task: types.TaskMetadata | None = None, + ) -> types.JSONRPCRequest: + """Build a sampling/createMessage request without sending it. + + Args: + messages: The conversation messages to send + max_tokens: Maximum number of tokens to generate + system_prompt: Optional system prompt + include_context: Optional context inclusion setting + temperature: Optional sampling temperature + stop_sequences: Optional stop sequences + metadata: Optional metadata to pass through to the LLM provider + model_preferences: Optional model selection preferences + tools: Optional list of tools the LLM can use during sampling + tool_choice: Optional control over tool usage behavior + related_task_id: If provided, adds io.modelcontextprotocol/related-task metadata + task: If provided, makes this a task-augmented request + + Returns: + A JSONRPCRequest ready to be sent or queued + """ + params = types.CreateMessageRequestParams( + messages=messages, + systemPrompt=system_prompt, + includeContext=include_context, + temperature=temperature, + maxTokens=max_tokens, + stopSequences=stop_sequences, + metadata=metadata, + modelPreferences=model_preferences, + tools=tools, + toolChoice=tool_choice, + task=task, ) + params_data = params.model_dump(by_alias=True, mode="json", exclude_none=True) + + # Add related-task metadata if associated with a parent task + if related_task_id is not None: + # Defensive: model_dump() never includes _meta, but guard against future changes + if "_meta" not in params_data: # pragma: no cover + params_data["_meta"] = {} + params_data["_meta"][RELATED_TASK_METADATA_KEY] = types.RelatedTaskMetadata( + taskId=related_task_id + ).model_dump(by_alias=True) + + request_id = f"task-{related_task_id}-{id(params)}" if related_task_id else self._request_id + if related_task_id is None: + self._request_id += 1 + + return types.JSONRPCRequest( + jsonrpc="2.0", + id=request_id, + method="sampling/createMessage", + params=params_data, + ) + + async def send_message(self, message: SessionMessage) -> None: + """Send a raw session message. + + This is primarily used by TaskResultHandler to deliver queued messages + (elicitation/sampling requests) to the client during task execution. + + WARNING: This is a low-level experimental method that may change without + notice. Prefer using higher-level methods like send_notification() or + send_request() for normal operations. + + Args: + message: The session message to send + """ + await self._write_stream.send(message) async def _handle_incoming(self, req: ServerRequestResponder) -> None: await self._incoming_message_stream_writer.send(req) diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index d051c25bf6..19af93fd16 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -10,7 +10,7 @@ # Create Starlette routes for SSE and message handling routes = [ - Route("/sse", endpoint=handle_sse), + Route("/sse", endpoint=handle_sse, methods=["GET"]), Mount("/messages/", app=sse.handle_post_message), ] @@ -22,12 +22,18 @@ async def handle_sse(request): await app.run( streams[0], streams[1], app.create_initialization_options() ) + # Return empty response to avoid NoneType error + return Response() # Create and run Starlette app starlette_app = Starlette(routes=routes) - uvicorn.run(starlette_app, host="0.0.0.0", port=port) + uvicorn.run(starlette_app, host="127.0.0.1", port=port) ``` +Note: The handle_sse function must return a Response to avoid a "TypeError: 'NoneType' +object is not callable" error when client disconnects. The example above returns +an empty Response() after the SSE connection ends to fix this. + See SseServerTransport class documentation for more details. """ @@ -46,6 +52,11 @@ async def handle_sse(request): from starlette.types import Receive, Scope, Send import mcp.types as types +from mcp.server.transport_security import ( + TransportSecurityMiddleware, + TransportSecuritySettings, +) +from mcp.shared.message import ServerMessageMetadata, SessionMessage logger = logging.getLogger(__name__) @@ -63,79 +74,139 @@ class SseServerTransport: """ _endpoint: str - _read_stream_writers: dict[ - UUID, MemoryObjectSendStream[types.JSONRPCMessage | Exception] - ] + _read_stream_writers: dict[UUID, MemoryObjectSendStream[SessionMessage | Exception]] + _security: TransportSecurityMiddleware - def __init__(self, endpoint: str) -> None: + def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | None = None) -> None: """ Creates a new SSE server transport, which will direct the client to POST - messages to the relative or absolute URL given. + messages to the relative path given. + + Args: + endpoint: A relative path where messages should be posted + (e.g., "/messages/"). + security_settings: Optional security settings for DNS rebinding protection. + + Note: + We use relative paths instead of full URLs for several reasons: + 1. Security: Prevents cross-origin requests by ensuring clients only connect + to the same origin they established the SSE connection with + 2. Flexibility: The server can be mounted at any path without needing to + know its full URL + 3. Portability: The same endpoint configuration works across different + environments (development, staging, production) + + Raises: + ValueError: If the endpoint is a full URL instead of a relative path """ super().__init__() + + # Validate that endpoint is a relative path and not a full URL + if "://" in endpoint or endpoint.startswith("//") or "?" in endpoint or "#" in endpoint: + raise ValueError( + f"Given endpoint: {endpoint} is not a relative path (e.g., '/messages/'), " + "expecting a relative path (e.g., '/messages/')." + ) + + # Ensure endpoint starts with a forward slash + if not endpoint.startswith("/"): + endpoint = "/" + endpoint + self._endpoint = endpoint self._read_stream_writers = {} + self._security = TransportSecurityMiddleware(security_settings) logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}") @asynccontextmanager - async def connect_sse(self, scope: Scope, receive: Receive, send: Send): + async def connect_sse(self, scope: Scope, receive: Receive, send: Send): # pragma: no cover if scope["type"] != "http": logger.error("connect_sse received non-HTTP request") raise ValueError("connect_sse can only handle HTTP requests") + # Validate request headers for DNS rebinding protection + request = Request(scope, receive) + error_response = await self._security.validate_request(request, is_post=False) + if error_response: + await error_response(scope, receive, send) + raise ValueError("Request validation failed") + logger.debug("Setting up SSE connection") - read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception] - read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception] + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] + read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - write_stream: MemoryObjectSendStream[types.JSONRPCMessage] - write_stream_reader: MemoryObjectReceiveStream[types.JSONRPCMessage] + write_stream: MemoryObjectSendStream[SessionMessage] + write_stream_reader: MemoryObjectReceiveStream[SessionMessage] read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) session_id = uuid4() - session_uri = f"{quote(self._endpoint)}?session_id={session_id.hex}" self._read_stream_writers[session_id] = read_stream_writer logger.debug(f"Created new session with ID: {session_id}") - sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[ - dict[str, Any] - ](0) + # Determine the full path for the message endpoint to be sent to the client. + # scope['root_path'] is the prefix where the current Starlette app + # instance is mounted. + # e.g., "" if top-level, or "/api_prefix" if mounted under "/api_prefix". + root_path = scope.get("root_path", "") + + # self._endpoint is the path *within* this app, e.g., "/messages". + # Concatenating them gives the full absolute path from the server root. + # e.g., "" + "/messages" -> "/messages" + # e.g., "/api_prefix" + "/messages" -> "/api_prefix/messages" + full_message_path_for_client = root_path.rstrip("/") + self._endpoint + + # This is the URI (path + query) the client will use to POST messages. + client_post_uri_data = f"{quote(full_message_path_for_client)}?session_id={session_id.hex}" + + sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[dict[str, Any]](0) async def sse_writer(): logger.debug("Starting SSE writer") async with sse_stream_writer, write_stream_reader: - await sse_stream_writer.send({"event": "endpoint", "data": session_uri}) - logger.debug(f"Sent endpoint event: {session_uri}") + await sse_stream_writer.send({"event": "endpoint", "data": client_post_uri_data}) + logger.debug(f"Sent endpoint event: {client_post_uri_data}") - async for message in write_stream_reader: - logger.debug(f"Sending message via SSE: {message}") + async for session_message in write_stream_reader: + logger.debug(f"Sending message via SSE: {session_message}") await sse_stream_writer.send( { "event": "message", - "data": message.model_dump_json( - by_alias=True, exclude_none=True - ), + "data": session_message.message.model_dump_json(by_alias=True, exclude_none=True), } ) async with anyio.create_task_group() as tg: - response = EventSourceResponse( - content=sse_stream_reader, data_sender_callable=sse_writer - ) + + async def response_wrapper(scope: Scope, receive: Receive, send: Send): + """ + The EventSourceResponse returning signals a client close / disconnect. + In this case we close our side of the streams to signal the client that + the connection has been closed. + """ + await EventSourceResponse(content=sse_stream_reader, data_sender_callable=sse_writer)( + scope, receive, send + ) + await read_stream_writer.aclose() + await write_stream_reader.aclose() + logging.debug(f"Client session disconnected {session_id}") + logger.debug("Starting SSE response task") - tg.start_soon(response, scope, receive, send) + tg.start_soon(response_wrapper, scope, receive, send) logger.debug("Yielding read and write streams") yield (read_stream, write_stream) - async def handle_post_message( - self, scope: Scope, receive: Receive, send: Send - ) -> None: + async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None: # pragma: no cover logger.debug("Handling POST message") request = Request(scope, receive) + # Validate request headers for DNS rebinding protection + error_response = await self._security.validate_request(request, is_post=True) + if error_response: + return await error_response(scope, receive, send) + session_id_param = request.query_params.get("session_id") if session_id_param is None: logger.warning("Received request without session_id") @@ -163,13 +234,16 @@ async def handle_post_message( message = types.JSONRPCMessage.model_validate_json(body) logger.debug(f"Validated client message: {message}") except ValidationError as err: - logger.error(f"Failed to parse message: {err}") + logger.exception("Failed to parse message") response = Response("Could not parse message", status_code=400) await response(scope, receive, send) await writer.send(err) return - logger.debug(f"Sending message to writer: {message}") + # Pass the ASGI scope for framework-agnostic access to request data + metadata = ServerMessageMetadata(request_context=request) + session_message = SessionMessage(message, metadata=metadata) + logger.debug(f"Sending session message to writer: {session_message}") response = Response("Accepted", status_code=202) await response(scope, receive, send) - await writer.send(message) + await writer.send(session_message) diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 0e0e491292..bcb9247abb 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -27,6 +27,7 @@ async def run_server(): from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream import mcp.types as types +from mcp.shared.message import SessionMessage @asynccontextmanager @@ -47,11 +48,11 @@ async def stdio_server( if not stdout: stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")) - read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception] - read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception] + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] + read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - write_stream: MemoryObjectSendStream[types.JSONRPCMessage] - write_stream_reader: MemoryObjectReceiveStream[types.JSONRPCMessage] + write_stream: MemoryObjectSendStream[SessionMessage] + write_stream_reader: MemoryObjectReceiveStream[SessionMessage] read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) @@ -62,22 +63,23 @@ async def stdin_reader(): async for line in stdin: try: message = types.JSONRPCMessage.model_validate_json(line) - except Exception as exc: + except Exception as exc: # pragma: no cover await read_stream_writer.send(exc) continue - await read_stream_writer.send(message) - except anyio.ClosedResourceError: + session_message = SessionMessage(message) + await read_stream_writer.send(session_message) + except anyio.ClosedResourceError: # pragma: no cover await anyio.lowlevel.checkpoint() async def stdout_writer(): try: async with write_stream_reader: - async for message in write_stream_reader: - json = message.model_dump_json(by_alias=True, exclude_none=True) + async for session_message in write_stream_reader: + json = session_message.message.model_dump_json(by_alias=True, exclude_none=True) await stdout.write(json + "\n") await stdout.flush() - except anyio.ClosedResourceError: + except anyio.ClosedResourceError: # pragma: no cover await anyio.lowlevel.checkpoint() async with anyio.create_task_group() as tg: diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py new file mode 100644 index 0000000000..2613b530c4 --- /dev/null +++ b/src/mcp/server/streamable_http.py @@ -0,0 +1,1067 @@ +""" +StreamableHTTP Server Transport Module + +This module implements an HTTP transport layer with Streamable HTTP. + +The transport handles bidirectional communication using HTTP requests and +responses, with streaming support for long-running operations. +""" + +import json +import logging +import re +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator, Awaitable, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass +from http import HTTPStatus +from typing import Any + +import anyio +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from pydantic import ValidationError +from sse_starlette import EventSourceResponse +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import Receive, Scope, Send + +from mcp.server.transport_security import ( + TransportSecurityMiddleware, + TransportSecuritySettings, +) +from mcp.shared.message import ServerMessageMetadata, SessionMessage +from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS +from mcp.types import ( + DEFAULT_NEGOTIATED_VERSION, + INTERNAL_ERROR, + INVALID_PARAMS, + INVALID_REQUEST, + PARSE_ERROR, + ErrorData, + JSONRPCError, + JSONRPCMessage, + JSONRPCRequest, + JSONRPCResponse, + RequestId, +) + +logger = logging.getLogger(__name__) + + +# Header names +MCP_SESSION_ID_HEADER = "mcp-session-id" +MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version" +LAST_EVENT_ID_HEADER = "last-event-id" + +# Content types +CONTENT_TYPE_JSON = "application/json" +CONTENT_TYPE_SSE = "text/event-stream" + +# Special key for the standalone GET stream +GET_STREAM_KEY = "_GET_stream" + +# Session ID validation pattern (visible ASCII characters ranging from 0x21 to 0x7E) +# Pattern ensures entire string contains only valid characters by using ^ and $ anchors +SESSION_ID_PATTERN = re.compile(r"^[\x21-\x7E]+$") + +# Type aliases +StreamId = str +EventId = str + + +@dataclass +class EventMessage: + """ + A JSONRPCMessage with an optional event ID for stream resumability. + """ + + message: JSONRPCMessage + event_id: str | None = None + + +EventCallback = Callable[[EventMessage], Awaitable[None]] + + +class EventStore(ABC): + """ + Interface for resumability support via event storage. + """ + + @abstractmethod + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + """ + Stores an event for later retrieval. + + Args: + stream_id: ID of the stream the event belongs to + message: The JSON-RPC message to store, or None for priming events + + Returns: + The generated event ID for the stored event + """ + pass # pragma: no cover + + @abstractmethod + async def replay_events_after( + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + """ + Replays events that occurred after the specified event ID. + + Args: + last_event_id: The ID of the last event the client received + send_callback: A callback function to send events to the client + + Returns: + The stream ID of the replayed events + """ + pass # pragma: no cover + + +class StreamableHTTPServerTransport: + """ + HTTP server transport with event streaming support for MCP. + + Handles JSON-RPC messages in HTTP POST requests with SSE streaming. + Supports optional JSON responses and session management. + """ + + # Server notification streams for POST requests as well as standalone SSE stream + _read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] | None = None + _read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] | None = None + _write_stream: MemoryObjectSendStream[SessionMessage] | None = None + _write_stream_reader: MemoryObjectReceiveStream[SessionMessage] | None = None + _security: TransportSecurityMiddleware + + def __init__( + self, + mcp_session_id: str | None, + is_json_response_enabled: bool = False, + event_store: EventStore | None = None, + security_settings: TransportSecuritySettings | None = None, + retry_interval: int | None = None, + ) -> None: + """ + Initialize a new StreamableHTTP server transport. + + Args: + mcp_session_id: Optional session identifier for this connection. + Must contain only visible ASCII characters (0x21-0x7E). + is_json_response_enabled: If True, return JSON responses for requests + instead of SSE streams. Default is False. + event_store: Event store for resumability support. If provided, + resumability will be enabled, allowing clients to + reconnect and resume messages. + security_settings: Optional security settings for DNS rebinding protection. + retry_interval: Retry interval in milliseconds to suggest to clients in SSE + retry field. When set, the server will send a retry field in + SSE priming events to control client reconnection timing for + polling behavior. Only used when event_store is provided. + + Raises: + ValueError: If the session ID contains invalid characters. + """ + if mcp_session_id is not None and not SESSION_ID_PATTERN.fullmatch(mcp_session_id): + raise ValueError("Session ID must only contain visible ASCII characters (0x21-0x7E)") + + self.mcp_session_id = mcp_session_id + self.is_json_response_enabled = is_json_response_enabled + self._event_store = event_store + self._security = TransportSecurityMiddleware(security_settings) + self._retry_interval = retry_interval + self._request_streams: dict[ + RequestId, + tuple[ + MemoryObjectSendStream[EventMessage], + MemoryObjectReceiveStream[EventMessage], + ], + ] = {} + self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[dict[str, str]]] = {} + self._terminated = False + + @property + def is_terminated(self) -> bool: + """Check if this transport has been explicitly terminated.""" + return self._terminated + + def close_sse_stream(self, request_id: RequestId) -> None: # pragma: no cover + """Close SSE connection for a specific request without terminating the stream. + + This method closes the HTTP connection for the specified request, triggering + client reconnection. Events continue to be stored in the event store and will + be replayed when the client reconnects with Last-Event-ID. + + Use this to implement polling behavior during long-running operations - + client will reconnect after the retry interval specified in the priming event. + + Args: + request_id: The request ID whose SSE stream should be closed. + + Note: + This is a no-op if there is no active stream for the request ID. + Requires event_store to be configured for events to be stored during + the disconnect. + """ + writer = self._sse_stream_writers.pop(request_id, None) + if writer: + writer.close() + + # Also close and remove request streams + if request_id in self._request_streams: + send_stream, receive_stream = self._request_streams.pop(request_id) + send_stream.close() + receive_stream.close() + + def close_standalone_sse_stream(self) -> None: # pragma: no cover + """Close the standalone GET SSE stream, triggering client reconnection. + + This method closes the HTTP connection for the standalone GET stream used + for unsolicited server-to-client notifications. The client SHOULD reconnect + with Last-Event-ID to resume receiving notifications. + + Use this to implement polling behavior for the notification stream - + client will reconnect after the retry interval specified in the priming event. + + Note: + This is a no-op if there is no active standalone SSE stream. + Requires event_store to be configured for events to be stored during + the disconnect. + Currently, client reconnection for standalone GET streams is NOT + implemented - this is a known gap (see test_standalone_get_stream_reconnection). + """ + self.close_sse_stream(GET_STREAM_KEY) + + def _create_session_message( # pragma: no cover + self, + message: JSONRPCMessage, + request: Request, + request_id: RequestId, + protocol_version: str, + ) -> SessionMessage: + """Create a session message with metadata including close_sse_stream callback. + + The close_sse_stream callbacks are only provided when the client supports + resumability (protocol version >= 2025-11-25). Old clients can't resume if + the stream is closed early because they didn't receive a priming event. + """ + # Only provide close callbacks when client supports resumability + if self._event_store and protocol_version >= "2025-11-25": + + async def close_stream_callback() -> None: + self.close_sse_stream(request_id) + + async def close_standalone_stream_callback() -> None: + self.close_standalone_sse_stream() + + metadata = ServerMessageMetadata( + request_context=request, + close_sse_stream=close_stream_callback, + close_standalone_sse_stream=close_standalone_stream_callback, + ) + else: + metadata = ServerMessageMetadata(request_context=request) + + return SessionMessage(message, metadata=metadata) + + async def _maybe_send_priming_event( + self, + request_id: RequestId, + sse_stream_writer: MemoryObjectSendStream[dict[str, Any]], + protocol_version: str, + ) -> None: + """Send priming event for SSE resumability if event_store is configured. + + Only sends priming events to clients with protocol version >= 2025-11-25, + which includes the fix for handling empty SSE data. Older clients would + crash trying to parse empty data as JSON. + """ + if not self._event_store: + return + # Priming events have empty data which older clients cannot handle. + if protocol_version < "2025-11-25": + return + priming_event_id = await self._event_store.store_event( + str(request_id), # Convert RequestId to StreamId (str) + None, # Priming event has no payload + ) + priming_event: dict[str, str | int] = {"id": priming_event_id, "data": ""} + if self._retry_interval is not None: + priming_event["retry"] = self._retry_interval + await sse_stream_writer.send(priming_event) + + def _create_error_response( + self, + error_message: str, + status_code: HTTPStatus, + error_code: int = INVALID_REQUEST, + headers: dict[str, str] | None = None, + ) -> Response: + """Create an error response with a simple string message.""" + response_headers = {"Content-Type": CONTENT_TYPE_JSON} + if headers: # pragma: no cover + response_headers.update(headers) + + if self.mcp_session_id: + response_headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id + + # Return a properly formatted JSON error response + error_response = JSONRPCError( + jsonrpc="2.0", + id="server-error", # We don't have a request ID for general errors + error=ErrorData( + code=error_code, + message=error_message, + ), + ) + + return Response( + error_response.model_dump_json(by_alias=True, exclude_none=True), + status_code=status_code, + headers=response_headers, + ) + + def _create_json_response( # pragma: no cover + self, + response_message: JSONRPCMessage | None, + status_code: HTTPStatus = HTTPStatus.OK, + headers: dict[str, str] | None = None, + ) -> Response: + """Create a JSON response from a JSONRPCMessage""" + response_headers = {"Content-Type": CONTENT_TYPE_JSON} + if headers: + response_headers.update(headers) + + if self.mcp_session_id: + response_headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id + + return Response( + response_message.model_dump_json(by_alias=True, exclude_none=True) if response_message else None, + status_code=status_code, + headers=response_headers, + ) + + def _get_session_id(self, request: Request) -> str | None: # pragma: no cover + """Extract the session ID from request headers.""" + return request.headers.get(MCP_SESSION_ID_HEADER) + + def _create_event_data(self, event_message: EventMessage) -> dict[str, str]: # pragma: no cover + """Create event data dictionary from an EventMessage.""" + event_data = { + "event": "message", + "data": event_message.message.model_dump_json(by_alias=True, exclude_none=True), + } + + # If an event ID was provided, include it + if event_message.event_id: + event_data["id"] = event_message.event_id + + return event_data + + async def _clean_up_memory_streams(self, request_id: RequestId) -> None: # pragma: no cover + """Clean up memory streams for a given request ID.""" + if request_id in self._request_streams: + try: + # Close the request stream + await self._request_streams[request_id][0].aclose() + await self._request_streams[request_id][1].aclose() + except Exception: + # During cleanup, we catch all exceptions since streams might be in various states + logger.debug("Error closing memory streams - may already be closed") + finally: + # Remove the request stream from the mapping + self._request_streams.pop(request_id, None) + + async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None: + """Application entry point that handles all HTTP requests""" + request = Request(scope, receive) + + # Validate request headers for DNS rebinding protection + is_post = request.method == "POST" + error_response = await self._security.validate_request(request, is_post=is_post) + if error_response: # pragma: no cover + await error_response(scope, receive, send) + return + + if self._terminated: # pragma: no cover + # If the session has been terminated, return 404 Not Found + response = self._create_error_response( + "Not Found: Session has been terminated", + HTTPStatus.NOT_FOUND, + ) + await response(scope, receive, send) + return + + if request.method == "POST": + await self._handle_post_request(scope, request, receive, send) + elif request.method == "GET": # pragma: no cover + await self._handle_get_request(request, send) + elif request.method == "DELETE": # pragma: no cover + await self._handle_delete_request(request, send) + else: # pragma: no cover + await self._handle_unsupported_request(request, send) + + def _check_accept_headers(self, request: Request) -> tuple[bool, bool]: + """Check if the request accepts the required media types.""" + accept_header = request.headers.get("accept", "") + accept_types = [media_type.strip() for media_type in accept_header.split(",")] + + has_json = any(media_type.startswith(CONTENT_TYPE_JSON) for media_type in accept_types) + has_sse = any(media_type.startswith(CONTENT_TYPE_SSE) for media_type in accept_types) + + return has_json, has_sse + + def _check_content_type(self, request: Request) -> bool: + """Check if the request has the correct Content-Type.""" + content_type = request.headers.get("content-type", "") + content_type_parts = [part.strip() for part in content_type.split(";")[0].split(",")] + + return any(part == CONTENT_TYPE_JSON for part in content_type_parts) + + async def _validate_accept_header(self, request: Request, scope: Scope, send: Send) -> bool: # pragma: no cover + """Validate Accept header based on response mode. Returns True if valid.""" + has_json, has_sse = self._check_accept_headers(request) + if self.is_json_response_enabled: + # For JSON-only responses, only require application/json + if not has_json: + response = self._create_error_response( + "Not Acceptable: Client must accept application/json", + HTTPStatus.NOT_ACCEPTABLE, + ) + await response(scope, request.receive, send) + return False + # For SSE responses, require both content types + elif not (has_json and has_sse): + response = self._create_error_response( + "Not Acceptable: Client must accept both application/json and text/event-stream", + HTTPStatus.NOT_ACCEPTABLE, + ) + await response(scope, request.receive, send) + return False + return True + + async def _handle_post_request(self, scope: Scope, request: Request, receive: Receive, send: Send) -> None: + """Handle POST requests containing JSON-RPC messages.""" + writer = self._read_stream_writer + if writer is None: # pragma: no cover + raise ValueError("No read stream writer available. Ensure connect() is called first.") + try: + # Validate Accept header + if not await self._validate_accept_header(request, scope, send): + return + + # Validate Content-Type + if not self._check_content_type(request): # pragma: no cover + response = self._create_error_response( + "Unsupported Media Type: Content-Type must be application/json", + HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + ) + await response(scope, receive, send) + return + + # Parse the body - only read it once + body = await request.body() + + try: + raw_message = json.loads(body) + except json.JSONDecodeError as e: + response = self._create_error_response(f"Parse error: {str(e)}", HTTPStatus.BAD_REQUEST, PARSE_ERROR) + await response(scope, receive, send) + return + + try: # pragma: no cover + message = JSONRPCMessage.model_validate(raw_message) + except ValidationError as e: # pragma: no cover + response = self._create_error_response( + f"Validation error: {str(e)}", + HTTPStatus.BAD_REQUEST, + INVALID_PARAMS, + ) + await response(scope, receive, send) + return + + # Check if this is an initialization request + is_initialization_request = ( + isinstance(message.root, JSONRPCRequest) and message.root.method == "initialize" + ) # pragma: no cover + + if is_initialization_request: # pragma: no cover + # Check if the server already has an established session + if self.mcp_session_id: + # Check if request has a session ID + request_session_id = self._get_session_id(request) + + # If request has a session ID but doesn't match, return 404 + if request_session_id and request_session_id != self.mcp_session_id: + response = self._create_error_response( + "Not Found: Invalid or expired session ID", + HTTPStatus.NOT_FOUND, + ) + await response(scope, receive, send) + return + elif not await self._validate_request_headers(request, send): # pragma: no cover + return + + # For notifications and responses only, return 202 Accepted + if not isinstance(message.root, JSONRPCRequest): # pragma: no cover + # Create response object and send it + response = self._create_json_response( + None, + HTTPStatus.ACCEPTED, + ) + await response(scope, receive, send) + + # Process the message after sending the response + metadata = ServerMessageMetadata(request_context=request) + session_message = SessionMessage(message, metadata=metadata) + await writer.send(session_message) + + return + + # Extract protocol version for priming event decision. + # For initialize requests, get from request params. + # For other requests, get from header (already validated). + protocol_version = ( + str(message.root.params.get("protocolVersion", DEFAULT_NEGOTIATED_VERSION)) + if is_initialization_request and message.root.params + else request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION) + ) + + # Extract the request ID outside the try block for proper scope + request_id = str(message.root.id) # pragma: no cover + # Register this stream for the request ID + self._request_streams[request_id] = anyio.create_memory_object_stream[EventMessage](0) # pragma: no cover + request_stream_reader = self._request_streams[request_id][1] # pragma: no cover + + if self.is_json_response_enabled: # pragma: no cover + # Process the message + metadata = ServerMessageMetadata(request_context=request) + session_message = SessionMessage(message, metadata=metadata) + await writer.send(session_message) + try: + # Process messages from the request-specific stream + # We need to collect all messages until we get a response + response_message = None + + # Use similar approach to SSE writer for consistency + async for event_message in request_stream_reader: + # If it's a response, this is what we're waiting for + if isinstance(event_message.message.root, JSONRPCResponse | JSONRPCError): + response_message = event_message.message + break + # For notifications and request, keep waiting + else: + logger.debug(f"received: {event_message.message.root.method}") + + # At this point we should have a response + if response_message: + # Create JSON response + response = self._create_json_response(response_message) + await response(scope, receive, send) + else: + # This shouldn't happen in normal operation + logger.error("No response message received before stream closed") + response = self._create_error_response( + "Error processing request: No response received", + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + await response(scope, receive, send) + except Exception: + logger.exception("Error processing JSON response") + response = self._create_error_response( + "Error processing request", + HTTPStatus.INTERNAL_SERVER_ERROR, + INTERNAL_ERROR, + ) + await response(scope, receive, send) + finally: + await self._clean_up_memory_streams(request_id) + else: # pragma: no cover + # Create SSE stream + sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[dict[str, str]](0) + + # Store writer reference so close_sse_stream() can close it + self._sse_stream_writers[request_id] = sse_stream_writer + + async def sse_writer(): + # Get the request ID from the incoming request message + try: + async with sse_stream_writer, request_stream_reader: + # Send priming event for SSE resumability + await self._maybe_send_priming_event(request_id, sse_stream_writer, protocol_version) + + # Process messages from the request-specific stream + async for event_message in request_stream_reader: + # Build the event data + event_data = self._create_event_data(event_message) + await sse_stream_writer.send(event_data) + + # If response, remove from pending streams and close + if isinstance( + event_message.message.root, + JSONRPCResponse | JSONRPCError, + ): + break + except anyio.ClosedResourceError: + # Expected when close_sse_stream() is called + logger.debug("SSE stream closed by close_sse_stream()") + except Exception: + logger.exception("Error in SSE writer") + finally: + logger.debug("Closing SSE writer") + self._sse_stream_writers.pop(request_id, None) + await self._clean_up_memory_streams(request_id) + + # Create and start EventSourceResponse + # SSE stream mode (original behavior) + # Set up headers + headers = { + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "Content-Type": CONTENT_TYPE_SSE, + **({MCP_SESSION_ID_HEADER: self.mcp_session_id} if self.mcp_session_id else {}), + } + response = EventSourceResponse( + content=sse_stream_reader, + data_sender_callable=sse_writer, + headers=headers, + ) + + # Start the SSE response (this will send headers immediately) + try: + # First send the response to establish the SSE connection + async with anyio.create_task_group() as tg: + tg.start_soon(response, scope, receive, send) + # Then send the message to be processed by the server + session_message = self._create_session_message(message, request, request_id, protocol_version) + await writer.send(session_message) + except Exception: + logger.exception("SSE response error") + await sse_stream_writer.aclose() + await sse_stream_reader.aclose() + await self._clean_up_memory_streams(request_id) + + except Exception as err: # pragma: no cover + logger.exception("Error handling POST request") + response = self._create_error_response( + f"Error handling POST request: {err}", + HTTPStatus.INTERNAL_SERVER_ERROR, + INTERNAL_ERROR, + ) + await response(scope, receive, send) + if writer: + await writer.send(Exception(err)) + return + + async def _handle_get_request(self, request: Request, send: Send) -> None: # pragma: no cover + """ + Handle GET request to establish SSE. + + This allows the server to communicate to the client without the client + first sending data via HTTP POST. The server can send JSON-RPC requests + and notifications on this stream. + """ + writer = self._read_stream_writer + if writer is None: + raise ValueError("No read stream writer available. Ensure connect() is called first.") + + # Validate Accept header - must include text/event-stream + _, has_sse = self._check_accept_headers(request) + + if not has_sse: + response = self._create_error_response( + "Not Acceptable: Client must accept text/event-stream", + HTTPStatus.NOT_ACCEPTABLE, + ) + await response(request.scope, request.receive, send) + return + + if not await self._validate_request_headers(request, send): + return + + # Handle resumability: check for Last-Event-ID header + if last_event_id := request.headers.get(LAST_EVENT_ID_HEADER): + await self._replay_events(last_event_id, request, send) + return + + headers = { + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "Content-Type": CONTENT_TYPE_SSE, + } + + if self.mcp_session_id: + headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id + + # Check if we already have an active GET stream + if GET_STREAM_KEY in self._request_streams: + response = self._create_error_response( + "Conflict: Only one SSE stream is allowed per session", + HTTPStatus.CONFLICT, + ) + await response(request.scope, request.receive, send) + return + + # Create SSE stream + sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[dict[str, str]](0) + + async def standalone_sse_writer(): + try: + # Create a standalone message stream for server-initiated messages + + self._request_streams[GET_STREAM_KEY] = anyio.create_memory_object_stream[EventMessage](0) + standalone_stream_reader = self._request_streams[GET_STREAM_KEY][1] + + async with sse_stream_writer, standalone_stream_reader: + # Process messages from the standalone stream + async for event_message in standalone_stream_reader: + # For the standalone stream, we handle: + # - JSONRPCNotification (server sends notifications to client) + # - JSONRPCRequest (server sends requests to client) + # We should NOT receive JSONRPCResponse + + # Send the message via SSE + event_data = self._create_event_data(event_message) + await sse_stream_writer.send(event_data) + except Exception: + logger.exception("Error in standalone SSE writer") + finally: + logger.debug("Closing standalone SSE writer") + await self._clean_up_memory_streams(GET_STREAM_KEY) + + # Create and start EventSourceResponse + response = EventSourceResponse( + content=sse_stream_reader, + data_sender_callable=standalone_sse_writer, + headers=headers, + ) + + try: + # This will send headers immediately and establish the SSE connection + await response(request.scope, request.receive, send) + except Exception: + logger.exception("Error in standalone SSE response") + await sse_stream_writer.aclose() + await sse_stream_reader.aclose() + await self._clean_up_memory_streams(GET_STREAM_KEY) + + async def _handle_delete_request(self, request: Request, send: Send) -> None: # pragma: no cover + """Handle DELETE requests for explicit session termination.""" + # Validate session ID + if not self.mcp_session_id: + # If no session ID set, return Method Not Allowed + response = self._create_error_response( + "Method Not Allowed: Session termination not supported", + HTTPStatus.METHOD_NOT_ALLOWED, + ) + await response(request.scope, request.receive, send) + return + + if not await self._validate_request_headers(request, send): + return + + await self.terminate() + + response = self._create_json_response( + None, + HTTPStatus.OK, + ) + await response(request.scope, request.receive, send) + + async def terminate(self) -> None: + """Terminate the current session, closing all streams. + + Once terminated, all requests with this session ID will receive 404 Not Found. + """ + + self._terminated = True + logger.info(f"Terminating session: {self.mcp_session_id}") + + # We need a copy of the keys to avoid modification during iteration + request_stream_keys = list(self._request_streams.keys()) + + # Close all request streams asynchronously + for key in request_stream_keys: # pragma: no cover + await self._clean_up_memory_streams(key) + + # Clear the request streams dictionary immediately + self._request_streams.clear() + try: + if self._read_stream_writer is not None: # pragma: no branch + await self._read_stream_writer.aclose() + if self._read_stream is not None: # pragma: no branch + await self._read_stream.aclose() + if self._write_stream_reader is not None: # pragma: no branch + await self._write_stream_reader.aclose() + if self._write_stream is not None: # pragma: no branch + await self._write_stream.aclose() + except Exception as e: # pragma: no cover + # During cleanup, we catch all exceptions since streams might be in various states + logger.debug(f"Error closing streams: {e}") + + async def _handle_unsupported_request(self, request: Request, send: Send) -> None: # pragma: no cover + """Handle unsupported HTTP methods.""" + headers = { + "Content-Type": CONTENT_TYPE_JSON, + "Allow": "GET, POST, DELETE", + } + if self.mcp_session_id: + headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id + + response = self._create_error_response( + "Method Not Allowed", + HTTPStatus.METHOD_NOT_ALLOWED, + headers=headers, + ) + await response(request.scope, request.receive, send) + + async def _validate_request_headers(self, request: Request, send: Send) -> bool: # pragma: no cover + if not await self._validate_session(request, send): + return False + if not await self._validate_protocol_version(request, send): + return False + return True + + async def _validate_session(self, request: Request, send: Send) -> bool: # pragma: no cover + """Validate the session ID in the request.""" + if not self.mcp_session_id: + # If we're not using session IDs, return True + return True + + # Get the session ID from the request headers + request_session_id = self._get_session_id(request) + + # If no session ID provided but required, return error + if not request_session_id: + response = self._create_error_response( + "Bad Request: Missing session ID", + HTTPStatus.BAD_REQUEST, + ) + await response(request.scope, request.receive, send) + return False + + # If session ID doesn't match, return error + if request_session_id != self.mcp_session_id: + response = self._create_error_response( + "Not Found: Invalid or expired session ID", + HTTPStatus.NOT_FOUND, + ) + await response(request.scope, request.receive, send) + return False + + return True + + async def _validate_protocol_version(self, request: Request, send: Send) -> bool: # pragma: no cover + """Validate the protocol version header in the request.""" + # Get the protocol version from the request headers + protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER) + + # If no protocol version provided, assume default version + if protocol_version is None: + protocol_version = DEFAULT_NEGOTIATED_VERSION + + # Check if the protocol version is supported + if protocol_version not in SUPPORTED_PROTOCOL_VERSIONS: + supported_versions = ", ".join(SUPPORTED_PROTOCOL_VERSIONS) + response = self._create_error_response( + f"Bad Request: Unsupported protocol version: {protocol_version}. " + + f"Supported versions: {supported_versions}", + HTTPStatus.BAD_REQUEST, + ) + await response(request.scope, request.receive, send) + return False + + return True + + async def _replay_events(self, last_event_id: str, request: Request, send: Send) -> None: # pragma: no cover + """ + Replays events that would have been sent after the specified event ID. + Only used when resumability is enabled. + """ + event_store = self._event_store + if not event_store: + return + + try: + headers = { + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "Content-Type": CONTENT_TYPE_SSE, + } + + if self.mcp_session_id: + headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id + + # Get protocol version from header (already validated in _validate_protocol_version) + replay_protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION) + + # Create SSE stream for replay + sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[dict[str, str]](0) + + async def replay_sender(): + try: + async with sse_stream_writer: + # Define an async callback for sending events + async def send_event(event_message: EventMessage) -> None: + event_data = self._create_event_data(event_message) + await sse_stream_writer.send(event_data) + + # Replay past events and get the stream ID + stream_id = await event_store.replay_events_after(last_event_id, send_event) + + # If stream ID not in mapping, create it + if stream_id and stream_id not in self._request_streams: + # Register SSE writer so close_sse_stream() can close it + self._sse_stream_writers[stream_id] = sse_stream_writer + + # Send priming event for this new connection + await self._maybe_send_priming_event(stream_id, sse_stream_writer, replay_protocol_version) + + # Create new request streams for this connection + self._request_streams[stream_id] = anyio.create_memory_object_stream[EventMessage](0) + msg_reader = self._request_streams[stream_id][1] + + # Forward messages to SSE + async with msg_reader: + async for event_message in msg_reader: + event_data = self._create_event_data(event_message) + + await sse_stream_writer.send(event_data) + except anyio.ClosedResourceError: + # Expected when close_sse_stream() is called + logger.debug("Replay SSE stream closed by close_sse_stream()") + except Exception: + logger.exception("Error in replay sender") + + # Create and start EventSourceResponse + response = EventSourceResponse( + content=sse_stream_reader, + data_sender_callable=replay_sender, + headers=headers, + ) + + try: + await response(request.scope, request.receive, send) + except Exception: + logger.exception("Error in replay response") + finally: + await sse_stream_writer.aclose() + await sse_stream_reader.aclose() + + except Exception: + logger.exception("Error replaying events") + response = self._create_error_response( + "Error replaying events", + HTTPStatus.INTERNAL_SERVER_ERROR, + INTERNAL_ERROR, + ) + await response(request.scope, request.receive, send) + + @asynccontextmanager + async def connect( + self, + ) -> AsyncGenerator[ + tuple[ + MemoryObjectReceiveStream[SessionMessage | Exception], + MemoryObjectSendStream[SessionMessage], + ], + None, + ]: + """Context manager that provides read and write streams for a connection. + + Yields: + Tuple of (read_stream, write_stream) for bidirectional communication + """ + + # Create the memory streams for this connection + + read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](0) + write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0) + + # Store the streams + self._read_stream_writer = read_stream_writer + self._read_stream = read_stream + self._write_stream_reader = write_stream_reader + self._write_stream = write_stream + + # Start a task group for message routing + async with anyio.create_task_group() as tg: + # Create a message router that distributes messages to request streams + async def message_router(): # pragma: no cover + try: + async for session_message in write_stream_reader: + # Determine which request stream(s) should receive this message + message = session_message.message + target_request_id = None + # Check if this is a response + if isinstance(message.root, JSONRPCResponse | JSONRPCError): + response_id = str(message.root.id) + # If this response is for an existing request stream, + # send it there + target_request_id = response_id + # Extract related_request_id from meta if it exists + elif ( + session_message.metadata is not None + and isinstance( + session_message.metadata, + ServerMessageMetadata, + ) + and session_message.metadata.related_request_id is not None + ): + target_request_id = str(session_message.metadata.related_request_id) + + request_stream_id = target_request_id if target_request_id is not None else GET_STREAM_KEY + + # Store the event if we have an event store, + # regardless of whether a client is connected + # messages will be replayed on the re-connect + event_id = None + if self._event_store: + event_id = await self._event_store.store_event(request_stream_id, message) + logger.debug(f"Stored {event_id} from {request_stream_id}") + + if request_stream_id in self._request_streams: + try: + # Send both the message and the event ID + await self._request_streams[request_stream_id][0].send(EventMessage(message, event_id)) + except ( + anyio.BrokenResourceError, + anyio.ClosedResourceError, + ): + # Stream might be closed, remove from registry + self._request_streams.pop(request_stream_id, None) + else: + logger.debug( + f"""Request stream {request_stream_id} not found + for message. Still processing message as the client + might reconnect and replay.""" + ) + except anyio.ClosedResourceError: + if self._terminated: + logger.debug("Read stream closed by client") + else: + logger.exception("Unexpected closure of read stream in message router") + except Exception: + logger.exception("Error in message router") + + # Start the message router + tg.start_soon(message_router) + + try: + # Yield the streams for the caller to use + yield read_stream, write_stream + finally: + for stream_id in list(self._request_streams.keys()): # pragma: no cover + await self._clean_up_memory_streams(stream_id) + self._request_streams.clear() + + # Clean up the read and write streams + try: + await read_stream_writer.aclose() + await read_stream.aclose() + await write_stream_reader.aclose() + await write_stream.aclose() + except Exception as e: # pragma: no cover + # During cleanup, we catch all exceptions since streams might be in various states + logger.debug(f"Error closing streams: {e}") diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py new file mode 100644 index 0000000000..50d2aefa29 --- /dev/null +++ b/src/mcp/server/streamable_http_manager.py @@ -0,0 +1,285 @@ +"""StreamableHTTP Session Manager for MCP servers.""" + +from __future__ import annotations + +import contextlib +import logging +from collections.abc import AsyncIterator +from http import HTTPStatus +from typing import Any +from uuid import uuid4 + +import anyio +from anyio.abc import TaskStatus +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import Receive, Scope, Send + +from mcp.server.lowlevel.server import Server as MCPServer +from mcp.server.streamable_http import ( + MCP_SESSION_ID_HEADER, + EventStore, + StreamableHTTPServerTransport, +) +from mcp.server.transport_security import TransportSecuritySettings + +logger = logging.getLogger(__name__) + + +class StreamableHTTPSessionManager: + """ + Manages StreamableHTTP sessions with optional resumability via event store. + + This class abstracts away the complexity of session management, event storage, + and request handling for StreamableHTTP transports. It handles: + + 1. Session tracking for clients + 2. Resumability via an optional event store + 3. Connection management and lifecycle + 4. Request handling and transport setup + + Important: Only one StreamableHTTPSessionManager instance should be created + per application. The instance cannot be reused after its run() context has + completed. If you need to restart the manager, create a new instance. + + Args: + app: The MCP server instance + event_store: Optional event store for resumability support. + If provided, enables resumable connections where clients + can reconnect and receive missed events. + If None, sessions are still tracked but not resumable. + json_response: Whether to use JSON responses instead of SSE streams + stateless: If True, creates a completely fresh transport for each request + with no session tracking or state persistence between requests. + security_settings: Optional transport security settings. + retry_interval: Retry interval in milliseconds to suggest to clients in SSE + retry field. Used for SSE polling behavior. + """ + + def __init__( + self, + app: MCPServer[Any, Any], + event_store: EventStore | None = None, + json_response: bool = False, + stateless: bool = False, + security_settings: TransportSecuritySettings | None = None, + retry_interval: int | None = None, + ): + self.app = app + self.event_store = event_store + self.json_response = json_response + self.stateless = stateless + self.security_settings = security_settings + self.retry_interval = retry_interval + + # Session tracking (only used if not stateless) + self._session_creation_lock = anyio.Lock() + self._server_instances: dict[str, StreamableHTTPServerTransport] = {} + + # The task group will be set during lifespan + self._task_group = None + # Thread-safe tracking of run() calls + self._run_lock = anyio.Lock() + self._has_started = False + + @contextlib.asynccontextmanager + async def run(self) -> AsyncIterator[None]: + """ + Run the session manager with proper lifecycle management. + + This creates and manages the task group for all session operations. + + Important: This method can only be called once per instance. The same + StreamableHTTPSessionManager instance cannot be reused after this + context manager exits. Create a new instance if you need to restart. + + Use this in the lifespan context manager of your Starlette app: + + @contextlib.asynccontextmanager + async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with session_manager.run(): + yield + """ + # Thread-safe check to ensure run() is only called once + async with self._run_lock: + if self._has_started: + raise RuntimeError( + "StreamableHTTPSessionManager .run() can only be called " + "once per instance. Create a new instance if you need to run again." + ) + self._has_started = True + + async with anyio.create_task_group() as tg: + # Store the task group for later use + self._task_group = tg + logger.info("StreamableHTTP session manager started") + try: + yield # Let the application run + finally: + logger.info("StreamableHTTP session manager shutting down") + # Cancel task group to stop all spawned tasks + tg.cancel_scope.cancel() + self._task_group = None + # Clear any remaining server instances + self._server_instances.clear() + + async def handle_request( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + """ + Process ASGI request with proper session handling and transport setup. + + Dispatches to the appropriate handler based on stateless mode. + + Args: + scope: ASGI scope + receive: ASGI receive function + send: ASGI send function + """ + if self._task_group is None: + raise RuntimeError("Task group is not initialized. Make sure to use run().") + + # Dispatch to the appropriate handler + if self.stateless: + await self._handle_stateless_request(scope, receive, send) + else: + await self._handle_stateful_request(scope, receive, send) + + async def _handle_stateless_request( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + """ + Process request in stateless mode - creating a new transport for each request. + + Args: + scope: ASGI scope + receive: ASGI receive function + send: ASGI send function + """ + logger.debug("Stateless mode: Creating new transport for this request") + # No session ID needed in stateless mode + http_transport = StreamableHTTPServerTransport( + mcp_session_id=None, # No session tracking in stateless mode + is_json_response_enabled=self.json_response, + event_store=None, # No event store in stateless mode + security_settings=self.security_settings, + ) + + # Start server in a new task + async def run_stateless_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED): + async with http_transport.connect() as streams: + read_stream, write_stream = streams + task_status.started() + try: + await self.app.run( + read_stream, + write_stream, + self.app.create_initialization_options(), + stateless=True, + ) + except Exception: # pragma: no cover + logger.exception("Stateless session crashed") + + # Assert task group is not None for type checking + assert self._task_group is not None + # Start the server task + await self._task_group.start(run_stateless_server) + + # Handle the HTTP request and return the response + await http_transport.handle_request(scope, receive, send) + + # Terminate the transport after the request is handled + await http_transport.terminate() + + async def _handle_stateful_request( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + """ + Process request in stateful mode - maintaining session state between requests. + + Args: + scope: ASGI scope + receive: ASGI receive function + send: ASGI send function + """ + request = Request(scope, receive) + request_mcp_session_id = request.headers.get(MCP_SESSION_ID_HEADER) + + # Existing session case + if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances: # pragma: no cover + transport = self._server_instances[request_mcp_session_id] + logger.debug("Session already exists, handling request directly") + await transport.handle_request(scope, receive, send) + return + + if request_mcp_session_id is None: + # New session case + logger.debug("Creating new transport") + async with self._session_creation_lock: + new_session_id = uuid4().hex + http_transport = StreamableHTTPServerTransport( + mcp_session_id=new_session_id, + is_json_response_enabled=self.json_response, + event_store=self.event_store, # May be None (no resumability) + security_settings=self.security_settings, + retry_interval=self.retry_interval, + ) + + assert http_transport.mcp_session_id is not None + self._server_instances[http_transport.mcp_session_id] = http_transport + logger.info(f"Created new transport with session ID: {new_session_id}") + + # Define the server runner + async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED) -> None: + async with http_transport.connect() as streams: + read_stream, write_stream = streams + task_status.started() + try: + await self.app.run( + read_stream, + write_stream, + self.app.create_initialization_options(), + stateless=False, # Stateful mode + ) + except Exception as e: + logger.error( + f"Session {http_transport.mcp_session_id} crashed: {e}", + exc_info=True, + ) + finally: + # Only remove from instances if not terminated + if ( # pragma: no branch + http_transport.mcp_session_id + and http_transport.mcp_session_id in self._server_instances + and not http_transport.is_terminated + ): + logger.info( + "Cleaning up crashed session " + f"{http_transport.mcp_session_id} from " + "active instances." + ) + del self._server_instances[http_transport.mcp_session_id] + + # Assert task group is not None for type checking + assert self._task_group is not None + # Start the server task + await self._task_group.start(run_server) + + # Handle the HTTP request and return the response + await http_transport.handle_request(scope, receive, send) + else: # pragma: no cover + # Invalid session ID + response = Response( + "Bad Request: No valid session ID provided", + status_code=HTTPStatus.BAD_REQUEST, + ) + await response(scope, receive, send) diff --git a/src/mcp/server/transport_security.py b/src/mcp/server/transport_security.py new file mode 100644 index 0000000000..ee1e4505a7 --- /dev/null +++ b/src/mcp/server/transport_security.py @@ -0,0 +1,127 @@ +"""DNS rebinding protection for MCP server transports.""" + +import logging + +from pydantic import BaseModel, Field +from starlette.requests import Request +from starlette.responses import Response + +logger = logging.getLogger(__name__) + + +class TransportSecuritySettings(BaseModel): + """Settings for MCP transport security features. + + These settings help protect against DNS rebinding attacks by validating + incoming request headers. + """ + + enable_dns_rebinding_protection: bool = Field( + default=True, + description="Enable DNS rebinding protection (recommended for production)", + ) + + allowed_hosts: list[str] = Field( + default=[], + description="List of allowed Host header values. Only applies when " + + "enable_dns_rebinding_protection is True.", + ) + + allowed_origins: list[str] = Field( + default=[], + description="List of allowed Origin header values. Only applies when " + + "enable_dns_rebinding_protection is True.", + ) + + +class TransportSecurityMiddleware: + """Middleware to enforce DNS rebinding protection for MCP transport endpoints.""" + + def __init__(self, settings: TransportSecuritySettings | None = None): + # If not specified, disable DNS rebinding protection by default + # for backwards compatibility + self.settings = settings or TransportSecuritySettings(enable_dns_rebinding_protection=False) + + def _validate_host(self, host: str | None) -> bool: # pragma: no cover + """Validate the Host header against allowed values.""" + if not host: + logger.warning("Missing Host header in request") + return False + + # Check exact match first + if host in self.settings.allowed_hosts: + return True + + # Check wildcard port patterns + for allowed in self.settings.allowed_hosts: + if allowed.endswith(":*"): + # Extract base host from pattern + base_host = allowed[:-2] + # Check if the actual host starts with base host and has a port + if host.startswith(base_host + ":"): + return True + + logger.warning(f"Invalid Host header: {host}") + return False + + def _validate_origin(self, origin: str | None) -> bool: # pragma: no cover + """Validate the Origin header against allowed values.""" + # Origin can be absent for same-origin requests + if not origin: + return True + + # Check exact match first + if origin in self.settings.allowed_origins: + return True + + # Check wildcard port patterns + for allowed in self.settings.allowed_origins: + if allowed.endswith(":*"): + # Extract base origin from pattern + base_origin = allowed[:-2] + # Check if the actual origin starts with base origin and has a port + if origin.startswith(base_origin + ":"): + return True + + logger.warning(f"Invalid Origin header: {origin}") + return False + + def _validate_content_type(self, content_type: str | None) -> bool: # pragma: no cover + """Validate the Content-Type header for POST requests.""" + if not content_type: + logger.warning("Missing Content-Type header in POST request") + return False + + # Content-Type must start with application/json + if not content_type.lower().startswith("application/json"): + logger.warning(f"Invalid Content-Type header: {content_type}") + return False + + return True + + async def validate_request(self, request: Request, is_post: bool = False) -> Response | None: + """Validate request headers for DNS rebinding protection. + + Returns None if validation passes, or an error Response if validation fails. + """ + # Always validate Content-Type for POST requests + if is_post: # pragma: no branch + content_type = request.headers.get("content-type") + if not self._validate_content_type(content_type): # pragma: no cover + return Response("Invalid Content-Type header", status_code=400) + + # Skip remaining validation if DNS rebinding protection is disabled + if not self.settings.enable_dns_rebinding_protection: + return None + + # Validate Host header # pragma: no cover + host = request.headers.get("host") # pragma: no cover + if not self._validate_host(host): # pragma: no cover + return Response("Invalid Host header", status_code=421) # pragma: no cover + + # Validate Origin header # pragma: no cover + origin = request.headers.get("origin") # pragma: no cover + if not self._validate_origin(origin): # pragma: no cover + return Response("Invalid Origin header", status_code=403) # pragma: no cover + + return None # pragma: no cover diff --git a/src/mcp/server/validation.py b/src/mcp/server/validation.py new file mode 100644 index 0000000000..2ccd7056bd --- /dev/null +++ b/src/mcp/server/validation.py @@ -0,0 +1,104 @@ +""" +Shared validation functions for server requests. + +This module provides validation logic for sampling and elicitation requests +that is shared across normal and task-augmented code paths. +""" + +from mcp.shared.exceptions import McpError +from mcp.types import ( + INVALID_PARAMS, + ClientCapabilities, + ErrorData, + SamplingMessage, + Tool, + ToolChoice, +) + + +def check_sampling_tools_capability(client_caps: ClientCapabilities | None) -> bool: + """ + Check if the client supports sampling tools capability. + + Args: + client_caps: The client's declared capabilities + + Returns: + True if client supports sampling.tools, False otherwise + """ + if client_caps is None: + return False + if client_caps.sampling is None: + return False + if client_caps.sampling.tools is None: + return False + return True + + +def validate_sampling_tools( + client_caps: ClientCapabilities | None, + tools: list[Tool] | None, + tool_choice: ToolChoice | None, +) -> None: + """ + Validate that the client supports sampling tools if tools are being used. + + Args: + client_caps: The client's declared capabilities + tools: The tools list, if provided + tool_choice: The tool choice setting, if provided + + Raises: + McpError: If tools/tool_choice are provided but client doesn't support them + """ + if tools is not None or tool_choice is not None: + if not check_sampling_tools_capability(client_caps): + raise McpError( + ErrorData( + code=INVALID_PARAMS, + message="Client does not support sampling tools capability", + ) + ) + + +def validate_tool_use_result_messages(messages: list[SamplingMessage]) -> None: + """ + Validate tool_use/tool_result message structure per SEP-1577. + + This validation ensures: + 1. Messages with tool_result content contain ONLY tool_result content + 2. tool_result messages are preceded by a message with tool_use + 3. tool_result IDs match the tool_use IDs from the previous message + + See: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1577 + + Args: + messages: The list of sampling messages to validate + + Raises: + ValueError: If the message structure is invalid + """ + if not messages: + return + + last_content = messages[-1].content_as_list + has_tool_results = any(c.type == "tool_result" for c in last_content) + + previous_content = messages[-2].content_as_list if len(messages) >= 2 else None + has_previous_tool_use = previous_content and any(c.type == "tool_use" for c in previous_content) + + if has_tool_results: + # Per spec: "SamplingMessage with tool result content blocks + # MUST NOT contain other content types." + if any(c.type != "tool_result" for c in last_content): + raise ValueError("The last message must contain only tool_result content if any is present") + if previous_content is None: + raise ValueError("tool_result requires a previous message containing tool_use") + if not has_previous_tool_use: + raise ValueError("tool_result blocks do not match any tool_use in the previous message") + + if has_previous_tool_use and previous_content: + tool_use_ids = {c.id for c in previous_content if c.type == "tool_use"} + tool_result_ids = {c.toolUseId for c in last_content if c.type == "tool_result"} + if tool_use_ids != tool_result_ids: + raise ValueError("ids of tool_result blocks and tool_use blocks from previous message do not match") diff --git a/src/mcp/server/websocket.py b/src/mcp/server/websocket.py index aee855cf11..5d5efd16e9 100644 --- a/src/mcp/server/websocket.py +++ b/src/mcp/server/websocket.py @@ -8,11 +8,12 @@ from starlette.websockets import WebSocket import mcp.types as types +from mcp.shared.message import SessionMessage logger = logging.getLogger(__name__) -@asynccontextmanager +@asynccontextmanager # pragma: no cover async def websocket_server(scope: Scope, receive: Receive, send: Send): """ WebSocket server transport for MCP. This is an ASGI application, suitable to be @@ -22,11 +23,11 @@ async def websocket_server(scope: Scope, receive: Receive, send: Send): websocket = WebSocket(scope, receive, send) await websocket.accept(subprotocol="mcp") - read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception] - read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception] + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] + read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - write_stream: MemoryObjectSendStream[types.JSONRPCMessage] - write_stream_reader: MemoryObjectReceiveStream[types.JSONRPCMessage] + write_stream: MemoryObjectSendStream[SessionMessage] + write_stream_reader: MemoryObjectReceiveStream[SessionMessage] read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) @@ -41,15 +42,16 @@ async def ws_reader(): await read_stream_writer.send(exc) continue - await read_stream_writer.send(client_message) + session_message = SessionMessage(client_message) + await read_stream_writer.send(session_message) except anyio.ClosedResourceError: await websocket.close() async def ws_writer(): try: async with write_stream_reader: - async for message in write_stream_reader: - obj = message.model_dump_json(by_alias=True, exclude_none=True) + async for session_message in write_stream_reader: + obj = session_message.message.model_dump_json(by_alias=True, exclude_none=True) await websocket.send_text(obj) except anyio.ClosedResourceError: await websocket.close() diff --git a/src/mcp/shared/_httpx_utils.py b/src/mcp/shared/_httpx_utils.py new file mode 100644 index 0000000000..945ef80955 --- /dev/null +++ b/src/mcp/shared/_httpx_utils.py @@ -0,0 +1,87 @@ +"""Utilities for creating standardized httpx AsyncClient instances.""" + +from typing import Any, Protocol + +import httpx + +__all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_READ_TIMEOUT"] + +# Default MCP timeout configuration +MCP_DEFAULT_TIMEOUT = 30.0 # General operations (seconds) +MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0 # SSE streams - 5 minutes (seconds) + + +class McpHttpClientFactory(Protocol): # pragma: no branch + def __call__( # pragma: no branch + self, + headers: dict[str, str] | None = None, + timeout: httpx.Timeout | None = None, + auth: httpx.Auth | None = None, + ) -> httpx.AsyncClient: ... + + +def create_mcp_http_client( + headers: dict[str, str] | None = None, + timeout: httpx.Timeout | None = None, + auth: httpx.Auth | None = None, +) -> httpx.AsyncClient: + """Create a standardized httpx AsyncClient with MCP defaults. + + This function provides common defaults used throughout the MCP codebase: + - follow_redirects=True (always enabled) + - Default timeout of 30 seconds if not specified + + Args: + headers: Optional headers to include with all requests. + timeout: Request timeout as httpx.Timeout object. + Defaults to 30 seconds if not specified. + auth: Optional authentication handler. + + Returns: + Configured httpx.AsyncClient instance with MCP defaults. + + Note: + The returned AsyncClient must be used as a context manager to ensure + proper cleanup of connections. + + Examples: + # Basic usage with MCP defaults + async with create_mcp_http_client() as client: + response = await client.get("https://api.example.com") + + # With custom headers + headers = {"Authorization": "Bearer token"} + async with create_mcp_http_client(headers) as client: + response = await client.get("/endpoint") + + # With both custom headers and timeout + timeout = httpx.Timeout(60.0, read=300.0) + async with create_mcp_http_client(headers, timeout) as client: + response = await client.get("/long-request") + + # With authentication + from httpx import BasicAuth + auth = BasicAuth(username="user", password="pass") + async with create_mcp_http_client(headers, timeout, auth) as client: + response = await client.get("/protected-endpoint") + """ + # Set MCP defaults + kwargs: dict[str, Any] = { + "follow_redirects": True, + } + + # Handle timeout + if timeout is None: + kwargs["timeout"] = httpx.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT) + else: + kwargs["timeout"] = timeout + + # Handle headers + if headers is not None: + kwargs["headers"] = headers + + # Handle authentication + if auth is not None: # pragma: no cover + kwargs["auth"] = auth + + return httpx.AsyncClient(**kwargs) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py new file mode 100644 index 0000000000..d3290997e5 --- /dev/null +++ b/src/mcp/shared/auth.py @@ -0,0 +1,159 @@ +from typing import Any, Literal + +from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_validator + + +class OAuthToken(BaseModel): + """ + See https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 + """ + + access_token: str + token_type: Literal["Bearer"] = "Bearer" + expires_in: int | None = None + scope: str | None = None + refresh_token: str | None = None + + @field_validator("token_type", mode="before") + @classmethod + def normalize_token_type(cls, v: str | None) -> str | None: + if isinstance(v, str): + # Bearer is title-cased in the spec, so we normalize it + # https://datatracker.ietf.org/doc/html/rfc6750#section-4 + return v.title() + return v # pragma: no cover + + +class InvalidScopeError(Exception): + def __init__(self, message: str): + self.message = message + + +class InvalidRedirectUriError(Exception): + def __init__(self, message: str): + self.message = message + + +class OAuthClientMetadata(BaseModel): + """ + RFC 7591 OAuth 2.0 Dynamic Client Registration metadata. + See https://datatracker.ietf.org/doc/html/rfc7591#section-2 + for the full specification. + """ + + redirect_uris: list[AnyUrl] | None = Field(..., min_length=1) + # supported auth methods for the token endpoint + token_endpoint_auth_method: ( + Literal["none", "client_secret_post", "client_secret_basic", "private_key_jwt"] | None + ) = None + # supported grant_types of this implementation + grant_types: list[ + Literal["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"] | str + ] = [ + "authorization_code", + "refresh_token", + ] + # The MCP spec requires the "code" response type, but OAuth + # servers may also return additional types they support + response_types: list[str] = ["code"] + scope: str | None = None + + # these fields are currently unused, but we support & store them for potential + # future use + client_name: str | None = None + client_uri: AnyHttpUrl | None = None + logo_uri: AnyHttpUrl | None = None + contacts: list[str] | None = None + tos_uri: AnyHttpUrl | None = None + policy_uri: AnyHttpUrl | None = None + jwks_uri: AnyHttpUrl | None = None + jwks: Any | None = None + software_id: str | None = None + software_version: str | None = None + + def validate_scope(self, requested_scope: str | None) -> list[str] | None: + if requested_scope is None: + return None + requested_scopes = requested_scope.split(" ") + allowed_scopes = [] if self.scope is None else self.scope.split(" ") + for scope in requested_scopes: + if scope not in allowed_scopes: # pragma: no branch + raise InvalidScopeError(f"Client was not registered with scope {scope}") + return requested_scopes # pragma: no cover + + def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: + if redirect_uri is not None: + # Validate redirect_uri against client's registered redirect URIs + if self.redirect_uris is None or redirect_uri not in self.redirect_uris: + raise InvalidRedirectUriError(f"Redirect URI '{redirect_uri}' not registered for client") + return redirect_uri + elif self.redirect_uris is not None and len(self.redirect_uris) == 1: + return self.redirect_uris[0] + else: + raise InvalidRedirectUriError("redirect_uri must be specified when client has multiple registered URIs") + + +class OAuthClientInformationFull(OAuthClientMetadata): + """ + RFC 7591 OAuth 2.0 Dynamic Client Registration full response + (client information plus metadata). + """ + + client_id: str | None = None + client_secret: str | None = None + client_id_issued_at: int | None = None + client_secret_expires_at: int | None = None + + +class OAuthMetadata(BaseModel): + """ + RFC 8414 OAuth 2.0 Authorization Server Metadata. + See https://datatracker.ietf.org/doc/html/rfc8414#section-2 + """ + + issuer: AnyHttpUrl + authorization_endpoint: AnyHttpUrl + token_endpoint: AnyHttpUrl + registration_endpoint: AnyHttpUrl | None = None + scopes_supported: list[str] | None = None + response_types_supported: list[str] = ["code"] + response_modes_supported: list[str] | None = None + grant_types_supported: list[str] | None = None + token_endpoint_auth_methods_supported: list[str] | None = None + token_endpoint_auth_signing_alg_values_supported: list[str] | None = None + service_documentation: AnyHttpUrl | None = None + ui_locales_supported: list[str] | None = None + op_policy_uri: AnyHttpUrl | None = None + op_tos_uri: AnyHttpUrl | None = None + revocation_endpoint: AnyHttpUrl | None = None + revocation_endpoint_auth_methods_supported: list[str] | None = None + revocation_endpoint_auth_signing_alg_values_supported: list[str] | None = None + introspection_endpoint: AnyHttpUrl | None = None + introspection_endpoint_auth_methods_supported: list[str] | None = None + introspection_endpoint_auth_signing_alg_values_supported: list[str] | None = None + code_challenge_methods_supported: list[str] | None = None + client_id_metadata_document_supported: bool | None = None + + +class ProtectedResourceMetadata(BaseModel): + """ + RFC 9728 OAuth 2.0 Protected Resource Metadata. + See https://datatracker.ietf.org/doc/html/rfc9728#section-2 + """ + + resource: AnyHttpUrl + authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1) + jwks_uri: AnyHttpUrl | None = None + scopes_supported: list[str] | None = None + bearer_methods_supported: list[str] | None = Field(default=["header"]) # MCP only supports header method + resource_signing_alg_values_supported: list[str] | None = None + resource_name: str | None = None + resource_documentation: AnyHttpUrl | None = None + resource_policy_uri: AnyHttpUrl | None = None + resource_tos_uri: AnyHttpUrl | None = None + # tls_client_certificate_bound_access_tokens default is False, but ommited here for clarity + tls_client_certificate_bound_access_tokens: bool | None = None + authorization_details_types_supported: list[str] | None = None + dpop_signing_alg_values_supported: list[str] | None = None + # dpop_bound_access_tokens_required default is False, but ommited here for clarity + dpop_bound_access_tokens_required: bool | None = None diff --git a/src/mcp/shared/auth_utils.py b/src/mcp/shared/auth_utils.py new file mode 100644 index 0000000000..8f3c542f22 --- /dev/null +++ b/src/mcp/shared/auth_utils.py @@ -0,0 +1,85 @@ +"""Utilities for OAuth 2.0 Resource Indicators (RFC 8707) and PKCE (RFC 7636).""" + +import time +from urllib.parse import urlparse, urlsplit, urlunsplit + +from pydantic import AnyUrl, HttpUrl + + +def resource_url_from_server_url(url: str | HttpUrl | AnyUrl) -> str: + """Convert server URL to canonical resource URL per RFC 8707. + + RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component". + Returns absolute URI with lowercase scheme/host for canonical form. + + Args: + url: Server URL to convert + + Returns: + Canonical resource URL string + """ + # Convert to string if needed + url_str = str(url) + + # Parse the URL and remove fragment, create canonical form + parsed = urlsplit(url_str) + canonical = urlunsplit(parsed._replace(scheme=parsed.scheme.lower(), netloc=parsed.netloc.lower(), fragment="")) + + return canonical + + +def check_resource_allowed(requested_resource: str, configured_resource: str) -> bool: + """Check if a requested resource URL matches a configured resource URL. + + A requested resource matches if it has the same scheme, domain, port, + and its path starts with the configured resource's path. This allows + hierarchical matching where a token for a parent resource can be used + for child resources. + + Args: + requested_resource: The resource URL being requested + configured_resource: The resource URL that has been configured + + Returns: + True if the requested resource matches the configured resource + """ + # Parse both URLs + requested = urlparse(requested_resource) + configured = urlparse(configured_resource) + + # Compare scheme, host, and port (origin) + if requested.scheme.lower() != configured.scheme.lower() or requested.netloc.lower() != configured.netloc.lower(): + return False + + # Handle cases like requested=/foo and configured=/foo/ + requested_path = requested.path + configured_path = configured.path + + # If requested path is shorter, it cannot be a child + if len(requested_path) < len(configured_path): + return False + + # Check if the requested path starts with the configured path + # Ensure both paths end with / for proper comparison + # This ensures that paths like "/api123" don't incorrectly match "/api" + if not requested_path.endswith("/"): + requested_path += "/" + if not configured_path.endswith("/"): + configured_path += "/" + + return requested_path.startswith(configured_path) + + +def calculate_token_expiry(expires_in: int | str | None) -> float | None: + """Calculate token expiry timestamp from expires_in seconds. + + Args: + expires_in: Seconds until token expiration (may be string from some servers) + + Returns: + Unix timestamp when token expires, or None if no expiry specified + """ + if expires_in is None: + return None # pragma: no cover + # Defensive: handle servers that return expires_in as string + return time.time() + int(expires_in) diff --git a/src/mcp/shared/context.py b/src/mcp/shared/context.py index ae85d3a19b..5cf6588c9e 100644 --- a/src/mcp/shared/context.py +++ b/src/mcp/shared/context.py @@ -1,18 +1,32 @@ -from dataclasses import dataclass +""" +Request context for MCP handlers. +""" + +from dataclasses import dataclass, field from typing import Any, Generic from typing_extensions import TypeVar +from mcp.shared.message import CloseSSEStreamCallback from mcp.shared.session import BaseSession from mcp.types import RequestId, RequestParams SessionT = TypeVar("SessionT", bound=BaseSession[Any, Any, Any, Any, Any]) LifespanContextT = TypeVar("LifespanContextT") +RequestT = TypeVar("RequestT", default=Any) @dataclass -class RequestContext(Generic[SessionT, LifespanContextT]): +class RequestContext(Generic[SessionT, LifespanContextT, RequestT]): request_id: RequestId meta: RequestParams.Meta | None session: SessionT lifespan_context: LifespanContextT + # NOTE: This is typed as Any to avoid circular imports. The actual type is + # mcp.server.experimental.request_context.Experimental, but importing it here + # triggers mcp.server.__init__ -> fastmcp -> tools -> back to this module. + # The Server sets this to an Experimental instance at runtime. + experimental: Any = field(default=None) + request: RequestT | None = None + close_sse_stream: CloseSSEStreamCallback | None = None + close_standalone_sse_stream: CloseSSEStreamCallback | None = None diff --git a/src/mcp/shared/exceptions.py b/src/mcp/shared/exceptions.py index 97a1c09a9f..4943114912 100644 --- a/src/mcp/shared/exceptions.py +++ b/src/mcp/shared/exceptions.py @@ -1,4 +1,8 @@ -from mcp.types import ErrorData +from __future__ import annotations + +from typing import Any, cast + +from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData class McpError(Exception): @@ -12,3 +16,56 @@ def __init__(self, error: ErrorData): """Initialize McpError.""" super().__init__(error.message) self.error = error + + +class UrlElicitationRequiredError(McpError): + """ + Specialized error for when a tool requires URL mode elicitation(s) before proceeding. + + Servers can raise this error from tool handlers to indicate that the client + must complete one or more URL elicitations before the request can be processed. + + Example: + raise UrlElicitationRequiredError([ + ElicitRequestURLParams( + mode="url", + message="Authorization required for your files", + url="https://example.com/oauth/authorize", + elicitationId="auth-001" + ) + ]) + """ + + def __init__( + self, + elicitations: list[ElicitRequestURLParams], + message: str | None = None, + ): + """Initialize UrlElicitationRequiredError.""" + if message is None: + message = f"URL elicitation{'s' if len(elicitations) > 1 else ''} required" + + self._elicitations = elicitations + + error = ErrorData( + code=URL_ELICITATION_REQUIRED, + message=message, + data={"elicitations": [e.model_dump(by_alias=True, exclude_none=True) for e in elicitations]}, + ) + super().__init__(error) + + @property + def elicitations(self) -> list[ElicitRequestURLParams]: + """The list of URL elicitations required before the request can proceed.""" + return self._elicitations + + @classmethod + def from_error(cls, error: ErrorData) -> UrlElicitationRequiredError: + """Reconstruct from an ErrorData received over the wire.""" + if error.code != URL_ELICITATION_REQUIRED: + raise ValueError(f"Expected error code {URL_ELICITATION_REQUIRED}, got {error.code}") + + data = cast(dict[str, Any], error.data or {}) + raw_elicitations = cast(list[dict[str, Any]], data.get("elicitations", [])) + elicitations = [ElicitRequestURLParams.model_validate(e) for e in raw_elicitations] + return cls(elicitations, error.message) diff --git a/src/mcp/shared/experimental/__init__.py b/src/mcp/shared/experimental/__init__.py new file mode 100644 index 0000000000..9b1b1479cb --- /dev/null +++ b/src/mcp/shared/experimental/__init__.py @@ -0,0 +1,7 @@ +""" +Pure experimental MCP features (no server dependencies). + +WARNING: These APIs are experimental and may change without notice. + +For server-integrated experimental features, use mcp.server.experimental. +""" diff --git a/src/mcp/shared/experimental/tasks/__init__.py b/src/mcp/shared/experimental/tasks/__init__.py new file mode 100644 index 0000000000..37d81af50b --- /dev/null +++ b/src/mcp/shared/experimental/tasks/__init__.py @@ -0,0 +1,12 @@ +""" +Pure task state management for MCP. + +WARNING: These APIs are experimental and may change without notice. + +Import directly from submodules: +- mcp.shared.experimental.tasks.store.TaskStore +- mcp.shared.experimental.tasks.context.TaskContext +- mcp.shared.experimental.tasks.in_memory_task_store.InMemoryTaskStore +- mcp.shared.experimental.tasks.message_queue.TaskMessageQueue +- mcp.shared.experimental.tasks.helpers.is_terminal +""" diff --git a/src/mcp/shared/experimental/tasks/capabilities.py b/src/mcp/shared/experimental/tasks/capabilities.py new file mode 100644 index 0000000000..307fcdd6e5 --- /dev/null +++ b/src/mcp/shared/experimental/tasks/capabilities.py @@ -0,0 +1,115 @@ +""" +Tasks capability checking utilities. + +This module provides functions for checking and requiring task-related +capabilities. All tasks capability logic is centralized here to keep +the main session code clean. + +WARNING: These APIs are experimental and may change without notice. +""" + +from mcp.shared.exceptions import McpError +from mcp.types import ( + INVALID_REQUEST, + ClientCapabilities, + ClientTasksCapability, + ErrorData, +) + + +def check_tasks_capability( + required: ClientTasksCapability, + client: ClientTasksCapability, +) -> bool: + """ + Check if client's tasks capability matches the required capability. + + Args: + required: The capability being checked for + client: The client's declared capabilities + + Returns: + True if client has the required capability, False otherwise + """ + if required.requests is None: + return True + if client.requests is None: + return False + + # Check elicitation.create + if required.requests.elicitation is not None: + if client.requests.elicitation is None: + return False + if required.requests.elicitation.create is not None: + if client.requests.elicitation.create is None: + return False + + # Check sampling.createMessage + if required.requests.sampling is not None: + if client.requests.sampling is None: + return False + if required.requests.sampling.createMessage is not None: + if client.requests.sampling.createMessage is None: + return False + + return True + + +def has_task_augmented_elicitation(caps: ClientCapabilities) -> bool: + """Check if capabilities include task-augmented elicitation support.""" + if caps.tasks is None: + return False + if caps.tasks.requests is None: + return False + if caps.tasks.requests.elicitation is None: + return False + return caps.tasks.requests.elicitation.create is not None + + +def has_task_augmented_sampling(caps: ClientCapabilities) -> bool: + """Check if capabilities include task-augmented sampling support.""" + if caps.tasks is None: + return False + if caps.tasks.requests is None: + return False + if caps.tasks.requests.sampling is None: + return False + return caps.tasks.requests.sampling.createMessage is not None + + +def require_task_augmented_elicitation(client_caps: ClientCapabilities | None) -> None: + """ + Raise McpError if client doesn't support task-augmented elicitation. + + Args: + client_caps: The client's declared capabilities, or None if not initialized + + Raises: + McpError: If client doesn't support task-augmented elicitation + """ + if client_caps is None or not has_task_augmented_elicitation(client_caps): + raise McpError( + ErrorData( + code=INVALID_REQUEST, + message="Client does not support task-augmented elicitation", + ) + ) + + +def require_task_augmented_sampling(client_caps: ClientCapabilities | None) -> None: + """ + Raise McpError if client doesn't support task-augmented sampling. + + Args: + client_caps: The client's declared capabilities, or None if not initialized + + Raises: + McpError: If client doesn't support task-augmented sampling + """ + if client_caps is None or not has_task_augmented_sampling(client_caps): + raise McpError( + ErrorData( + code=INVALID_REQUEST, + message="Client does not support task-augmented sampling", + ) + ) diff --git a/src/mcp/shared/experimental/tasks/context.py b/src/mcp/shared/experimental/tasks/context.py new file mode 100644 index 0000000000..12d159515c --- /dev/null +++ b/src/mcp/shared/experimental/tasks/context.py @@ -0,0 +1,101 @@ +""" +TaskContext - Pure task state management. + +This module provides TaskContext, which manages task state without any +server/session dependencies. It can be used standalone for distributed +workers or wrapped by ServerTaskContext for full server integration. +""" + +from mcp.shared.experimental.tasks.store import TaskStore +from mcp.types import TASK_STATUS_COMPLETED, TASK_STATUS_FAILED, Result, Task + + +class TaskContext: + """ + Pure task state management - no session dependencies. + + This class handles: + - Task state (status, result) + - Cancellation tracking + - Store interactions + + For server-integrated features (elicit, create_message, notifications), + use ServerTaskContext from mcp.server.experimental. + + Example (distributed worker): + async def worker_job(task_id: str): + store = RedisTaskStore(redis_url) + task = await store.get_task(task_id) + ctx = TaskContext(task=task, store=store) + + await ctx.update_status("Working...") + result = await do_work() + await ctx.complete(result) + """ + + def __init__(self, task: Task, store: TaskStore): + self._task = task + self._store = store + self._cancelled = False + + @property + def task_id(self) -> str: + """The task identifier.""" + return self._task.taskId + + @property + def task(self) -> Task: + """The current task state.""" + return self._task + + @property + def is_cancelled(self) -> bool: + """Whether cancellation has been requested.""" + return self._cancelled + + def request_cancellation(self) -> None: + """ + Request cancellation of this task. + + This sets is_cancelled=True. Task work should check this + periodically and exit gracefully if set. + """ + self._cancelled = True + + async def update_status(self, message: str) -> None: + """ + Update the task's status message. + + Args: + message: The new status message + """ + self._task = await self._store.update_task( + self.task_id, + status_message=message, + ) + + async def complete(self, result: Result) -> None: + """ + Mark the task as completed with the given result. + + Args: + result: The task result + """ + await self._store.store_result(self.task_id, result) + self._task = await self._store.update_task( + self.task_id, + status=TASK_STATUS_COMPLETED, + ) + + async def fail(self, error: str) -> None: + """ + Mark the task as failed with an error message. + + Args: + error: The error message + """ + self._task = await self._store.update_task( + self.task_id, + status=TASK_STATUS_FAILED, + status_message=error, + ) diff --git a/src/mcp/shared/experimental/tasks/helpers.py b/src/mcp/shared/experimental/tasks/helpers.py new file mode 100644 index 0000000000..5c87f9ef87 --- /dev/null +++ b/src/mcp/shared/experimental/tasks/helpers.py @@ -0,0 +1,181 @@ +""" +Helper functions for pure task management. + +These helpers work with pure TaskContext and don't require server dependencies. +For server-integrated task helpers, use mcp.server.experimental. +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from uuid import uuid4 + +from mcp.shared.exceptions import McpError +from mcp.shared.experimental.tasks.context import TaskContext +from mcp.shared.experimental.tasks.store import TaskStore +from mcp.types import ( + INVALID_PARAMS, + TASK_STATUS_CANCELLED, + TASK_STATUS_COMPLETED, + TASK_STATUS_FAILED, + TASK_STATUS_WORKING, + CancelTaskResult, + ErrorData, + Task, + TaskMetadata, + TaskStatus, +) + +# Metadata key for model-immediate-response (per MCP spec) +# Servers MAY include this in CreateTaskResult._meta to provide an immediate +# response string while the task executes in the background. +MODEL_IMMEDIATE_RESPONSE_KEY = "io.modelcontextprotocol/model-immediate-response" + +# Metadata key for associating requests with a task (per MCP spec) +RELATED_TASK_METADATA_KEY = "io.modelcontextprotocol/related-task" + + +def is_terminal(status: TaskStatus) -> bool: + """ + Check if a task status represents a terminal state. + + Terminal states are those where the task has finished and will not change. + + Args: + status: The task status to check + + Returns: + True if the status is terminal (completed, failed, or cancelled) + """ + return status in (TASK_STATUS_COMPLETED, TASK_STATUS_FAILED, TASK_STATUS_CANCELLED) + + +async def cancel_task( + store: TaskStore, + task_id: str, +) -> CancelTaskResult: + """ + Cancel a task with spec-compliant validation. + + Per spec: "Receivers MUST reject cancellation of terminal status tasks + with -32602 (Invalid params)" + + This helper validates that the task exists and is not in a terminal state + before setting it to "cancelled". + + Args: + store: The task store + task_id: The task identifier to cancel + + Returns: + CancelTaskResult with the cancelled task state + + Raises: + McpError: With INVALID_PARAMS (-32602) if: + - Task does not exist + - Task is already in a terminal state (completed, failed, cancelled) + + Example: + @server.experimental.cancel_task() + async def handle_cancel(request: CancelTaskRequest) -> CancelTaskResult: + return await cancel_task(store, request.params.taskId) + """ + task = await store.get_task(task_id) + if task is None: + raise McpError( + ErrorData( + code=INVALID_PARAMS, + message=f"Task not found: {task_id}", + ) + ) + + if is_terminal(task.status): + raise McpError( + ErrorData( + code=INVALID_PARAMS, + message=f"Cannot cancel task in terminal state '{task.status}'", + ) + ) + + # Update task to cancelled status + cancelled_task = await store.update_task(task_id, status=TASK_STATUS_CANCELLED) + return CancelTaskResult(**cancelled_task.model_dump()) + + +def generate_task_id() -> str: + """Generate a unique task ID.""" + return str(uuid4()) + + +def create_task_state( + metadata: TaskMetadata, + task_id: str | None = None, +) -> Task: + """ + Create a Task object with initial state. + + This is a helper for TaskStore implementations. + + Args: + metadata: Task metadata + task_id: Optional task ID (generated if not provided) + + Returns: + A new Task in "working" status + """ + now = datetime.now(timezone.utc) + return Task( + taskId=task_id or generate_task_id(), + status=TASK_STATUS_WORKING, + createdAt=now, + lastUpdatedAt=now, + ttl=metadata.ttl, + pollInterval=500, # Default 500ms poll interval + ) + + +@asynccontextmanager +async def task_execution( + task_id: str, + store: TaskStore, +) -> AsyncIterator[TaskContext]: + """ + Context manager for safe task execution (pure, no server dependencies). + + Loads a task from the store and provides a TaskContext for the work. + If an unhandled exception occurs, the task is automatically marked as failed + and the exception is suppressed (since the failure is captured in task state). + + This is useful for distributed workers that don't have a server session. + + Args: + task_id: The task identifier to execute + store: The task store (must be accessible by the worker) + + Yields: + TaskContext for updating status and completing/failing the task + + Raises: + ValueError: If the task is not found in the store + + Example (distributed worker): + async def worker_process(task_id: str): + store = RedisTaskStore(redis_url) + async with task_execution(task_id, store) as ctx: + await ctx.update_status("Working...") + result = await do_work() + await ctx.complete(result) + """ + task = await store.get_task(task_id) + if task is None: + raise ValueError(f"Task {task_id} not found") + + ctx = TaskContext(task, store) + try: + yield ctx + except Exception as e: + # Auto-fail the task if an exception occurs and task isn't already terminal + # Exception is suppressed since failure is captured in task state + if not is_terminal(ctx.task.status): + await ctx.fail(str(e)) + # Don't re-raise - the failure is recorded in task state diff --git a/src/mcp/shared/experimental/tasks/in_memory_task_store.py b/src/mcp/shared/experimental/tasks/in_memory_task_store.py new file mode 100644 index 0000000000..7b630ce6e2 --- /dev/null +++ b/src/mcp/shared/experimental/tasks/in_memory_task_store.py @@ -0,0 +1,219 @@ +""" +In-memory implementation of TaskStore for demonstration purposes. + +This implementation stores all tasks in memory and provides automatic cleanup +based on the TTL duration specified in the task metadata using lazy expiration. + +Note: This is not suitable for production use as all data is lost on restart. +For production, consider implementing TaskStore with a database or distributed cache. +""" + +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone + +import anyio + +from mcp.shared.experimental.tasks.helpers import create_task_state, is_terminal +from mcp.shared.experimental.tasks.store import TaskStore +from mcp.types import Result, Task, TaskMetadata, TaskStatus + + +@dataclass +class StoredTask: + """Internal storage representation of a task.""" + + task: Task + result: Result | None = None + # Time when this task should be removed (None = never) + expires_at: datetime | None = field(default=None) + + +class InMemoryTaskStore(TaskStore): + """ + A simple in-memory implementation of TaskStore. + + Features: + - Automatic TTL-based cleanup (lazy expiration) + - Thread-safe for single-process async use + - Pagination support for list_tasks + + Limitations: + - All data lost on restart + - Not suitable for distributed systems + - No persistence + + For production, implement TaskStore with Redis, PostgreSQL, etc. + """ + + def __init__(self, page_size: int = 10) -> None: + self._tasks: dict[str, StoredTask] = {} + self._page_size = page_size + self._update_events: dict[str, anyio.Event] = {} + + def _calculate_expiry(self, ttl_ms: int | None) -> datetime | None: + """Calculate expiry time from TTL in milliseconds.""" + if ttl_ms is None: + return None + return datetime.now(timezone.utc) + timedelta(milliseconds=ttl_ms) + + def _is_expired(self, stored: StoredTask) -> bool: + """Check if a task has expired.""" + if stored.expires_at is None: + return False + return datetime.now(timezone.utc) >= stored.expires_at + + def _cleanup_expired(self) -> None: + """Remove all expired tasks. Called lazily during access operations.""" + expired_ids = [task_id for task_id, stored in self._tasks.items() if self._is_expired(stored)] + for task_id in expired_ids: + del self._tasks[task_id] + + async def create_task( + self, + metadata: TaskMetadata, + task_id: str | None = None, + ) -> Task: + """Create a new task with the given metadata.""" + # Cleanup expired tasks on access + self._cleanup_expired() + + task = create_task_state(metadata, task_id) + + if task.taskId in self._tasks: + raise ValueError(f"Task with ID {task.taskId} already exists") + + stored = StoredTask( + task=task, + expires_at=self._calculate_expiry(metadata.ttl), + ) + self._tasks[task.taskId] = stored + + # Return a copy to prevent external modification + return Task(**task.model_dump()) + + async def get_task(self, task_id: str) -> Task | None: + """Get a task by ID.""" + # Cleanup expired tasks on access + self._cleanup_expired() + + stored = self._tasks.get(task_id) + if stored is None: + return None + + # Return a copy to prevent external modification + return Task(**stored.task.model_dump()) + + async def update_task( + self, + task_id: str, + status: TaskStatus | None = None, + status_message: str | None = None, + ) -> Task: + """Update a task's status and/or message.""" + stored = self._tasks.get(task_id) + if stored is None: + raise ValueError(f"Task with ID {task_id} not found") + + # Per spec: Terminal states MUST NOT transition to any other status + if status is not None and status != stored.task.status and is_terminal(stored.task.status): + raise ValueError(f"Cannot transition from terminal status '{stored.task.status}'") + + status_changed = False + if status is not None and stored.task.status != status: + stored.task.status = status + status_changed = True + + if status_message is not None: + stored.task.statusMessage = status_message + + # Update lastUpdatedAt on any change + stored.task.lastUpdatedAt = datetime.now(timezone.utc) + + # If task is now terminal and has TTL, reset expiry timer + if status is not None and is_terminal(status) and stored.task.ttl is not None: + stored.expires_at = self._calculate_expiry(stored.task.ttl) + + # Notify waiters if status changed + if status_changed: + await self.notify_update(task_id) + + return Task(**stored.task.model_dump()) + + async def store_result(self, task_id: str, result: Result) -> None: + """Store the result for a task.""" + stored = self._tasks.get(task_id) + if stored is None: + raise ValueError(f"Task with ID {task_id} not found") + + stored.result = result + + async def get_result(self, task_id: str) -> Result | None: + """Get the stored result for a task.""" + stored = self._tasks.get(task_id) + if stored is None: + return None + + return stored.result + + async def list_tasks( + self, + cursor: str | None = None, + ) -> tuple[list[Task], str | None]: + """List tasks with pagination.""" + # Cleanup expired tasks on access + self._cleanup_expired() + + all_task_ids = list(self._tasks.keys()) + + start_index = 0 + if cursor is not None: + try: + cursor_index = all_task_ids.index(cursor) + start_index = cursor_index + 1 + except ValueError: + raise ValueError(f"Invalid cursor: {cursor}") + + page_task_ids = all_task_ids[start_index : start_index + self._page_size] + tasks = [Task(**self._tasks[tid].task.model_dump()) for tid in page_task_ids] + + # Determine next cursor + next_cursor = None + if start_index + self._page_size < len(all_task_ids) and page_task_ids: + next_cursor = page_task_ids[-1] + + return tasks, next_cursor + + async def delete_task(self, task_id: str) -> bool: + """Delete a task.""" + if task_id not in self._tasks: + return False + + del self._tasks[task_id] + return True + + async def wait_for_update(self, task_id: str) -> None: + """Wait until the task status changes.""" + if task_id not in self._tasks: + raise ValueError(f"Task with ID {task_id} not found") + + # Create a fresh event for waiting (anyio.Event can't be cleared) + self._update_events[task_id] = anyio.Event() + event = self._update_events[task_id] + await event.wait() + + async def notify_update(self, task_id: str) -> None: + """Signal that a task has been updated.""" + if task_id in self._update_events: + self._update_events[task_id].set() + + # --- Testing/debugging helpers --- + + def cleanup(self) -> None: + """Cleanup all tasks (useful for testing or graceful shutdown).""" + self._tasks.clear() + self._update_events.clear() + + def get_all_tasks(self) -> list[Task]: + """Get all tasks (useful for debugging). Returns copies to prevent modification.""" + self._cleanup_expired() + return [Task(**stored.task.model_dump()) for stored in self._tasks.values()] diff --git a/src/mcp/shared/experimental/tasks/message_queue.py b/src/mcp/shared/experimental/tasks/message_queue.py new file mode 100644 index 0000000000..69b6609887 --- /dev/null +++ b/src/mcp/shared/experimental/tasks/message_queue.py @@ -0,0 +1,241 @@ +""" +TaskMessageQueue - FIFO queue for task-related messages. + +This implements the core message queue pattern from the MCP Tasks spec. +When a handler needs to send a request (like elicitation) during a task-augmented +request, the message is enqueued instead of sent directly. Messages are delivered +to the client only through the `tasks/result` endpoint. + +This pattern enables: +1. Decoupling request handling from message delivery +2. Proper bidirectional communication via the tasks/result stream +3. Automatic status management (working <-> input_required) +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Literal + +import anyio + +from mcp.shared.experimental.tasks.resolver import Resolver +from mcp.types import JSONRPCNotification, JSONRPCRequest, RequestId + + +@dataclass +class QueuedMessage: + """ + A message queued for delivery via tasks/result. + + Messages are stored with their type and a resolver for requests + that expect responses. + """ + + type: Literal["request", "notification"] + """Whether this is a request (expects response) or notification (one-way).""" + + message: JSONRPCRequest | JSONRPCNotification + """The JSON-RPC message to send.""" + + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + """When the message was enqueued.""" + + resolver: Resolver[dict[str, Any]] | None = None + """Resolver to set when response arrives (only for requests).""" + + original_request_id: RequestId | None = None + """The original request ID used internally, for routing responses back.""" + + +class TaskMessageQueue(ABC): + """ + Abstract interface for task message queuing. + + This is a FIFO queue that stores messages to be delivered via `tasks/result`. + When a task-augmented handler calls elicit() or sends a notification, the + message is enqueued here instead of being sent directly to the client. + + The `tasks/result` handler then dequeues and sends these messages through + the transport, with `relatedRequestId` set to the tasks/result request ID + so responses are routed correctly. + + Implementations can use in-memory storage, Redis, etc. + """ + + @abstractmethod + async def enqueue(self, task_id: str, message: QueuedMessage) -> None: + """ + Add a message to the queue for a task. + + Args: + task_id: The task identifier + message: The message to enqueue + """ + + @abstractmethod + async def dequeue(self, task_id: str) -> QueuedMessage | None: + """ + Remove and return the next message from the queue. + + Args: + task_id: The task identifier + + Returns: + The next message, or None if queue is empty + """ + + @abstractmethod + async def peek(self, task_id: str) -> QueuedMessage | None: + """ + Return the next message without removing it. + + Args: + task_id: The task identifier + + Returns: + The next message, or None if queue is empty + """ + + @abstractmethod + async def is_empty(self, task_id: str) -> bool: + """ + Check if the queue is empty for a task. + + Args: + task_id: The task identifier + + Returns: + True if no messages are queued + """ + + @abstractmethod + async def clear(self, task_id: str) -> list[QueuedMessage]: + """ + Remove and return all messages from the queue. + + This is useful for cleanup when a task is cancelled or completed. + + Args: + task_id: The task identifier + + Returns: + All queued messages (may be empty) + """ + + @abstractmethod + async def wait_for_message(self, task_id: str) -> None: + """ + Wait until a message is available in the queue. + + This blocks until either: + 1. A message is enqueued for this task + 2. The wait is cancelled + + Args: + task_id: The task identifier + """ + + @abstractmethod + async def notify_message_available(self, task_id: str) -> None: + """ + Signal that a message is available for a task. + + This wakes up any coroutines waiting in wait_for_message(). + + Args: + task_id: The task identifier + """ + + +class InMemoryTaskMessageQueue(TaskMessageQueue): + """ + In-memory implementation of TaskMessageQueue. + + This is suitable for single-process servers. For distributed systems, + implement TaskMessageQueue with Redis, RabbitMQ, etc. + + Features: + - FIFO ordering per task + - Async wait for message availability + - Thread-safe for single-process async use + """ + + def __init__(self) -> None: + self._queues: dict[str, list[QueuedMessage]] = {} + self._events: dict[str, anyio.Event] = {} + + def _get_queue(self, task_id: str) -> list[QueuedMessage]: + """Get or create the queue for a task.""" + if task_id not in self._queues: + self._queues[task_id] = [] + return self._queues[task_id] + + async def enqueue(self, task_id: str, message: QueuedMessage) -> None: + """Add a message to the queue.""" + queue = self._get_queue(task_id) + queue.append(message) + # Signal that a message is available + await self.notify_message_available(task_id) + + async def dequeue(self, task_id: str) -> QueuedMessage | None: + """Remove and return the next message.""" + queue = self._get_queue(task_id) + if not queue: + return None + return queue.pop(0) + + async def peek(self, task_id: str) -> QueuedMessage | None: + """Return the next message without removing it.""" + queue = self._get_queue(task_id) + if not queue: + return None + return queue[0] + + async def is_empty(self, task_id: str) -> bool: + """Check if the queue is empty.""" + queue = self._get_queue(task_id) + return len(queue) == 0 + + async def clear(self, task_id: str) -> list[QueuedMessage]: + """Remove and return all messages.""" + queue = self._get_queue(task_id) + messages = list(queue) + queue.clear() + return messages + + async def wait_for_message(self, task_id: str) -> None: + """Wait until a message is available.""" + # Check if there are already messages + if not await self.is_empty(task_id): + return + + # Create a fresh event for waiting (anyio.Event can't be cleared) + self._events[task_id] = anyio.Event() + event = self._events[task_id] + + # Double-check after creating event (avoid race condition) + if not await self.is_empty(task_id): + return + + # Wait for a new message + await event.wait() + + async def notify_message_available(self, task_id: str) -> None: + """Signal that a message is available.""" + if task_id in self._events: + self._events[task_id].set() + + def cleanup(self, task_id: str | None = None) -> None: + """ + Clean up queues and events. + + Args: + task_id: If provided, clean up only this task. Otherwise clean up all. + """ + if task_id is not None: + self._queues.pop(task_id, None) + self._events.pop(task_id, None) + else: + self._queues.clear() + self._events.clear() diff --git a/src/mcp/shared/experimental/tasks/polling.py b/src/mcp/shared/experimental/tasks/polling.py new file mode 100644 index 0000000000..39db2e6b68 --- /dev/null +++ b/src/mcp/shared/experimental/tasks/polling.py @@ -0,0 +1,45 @@ +""" +Shared polling utilities for task operations. + +This module provides generic polling logic that works for both client→server +and server→client task polling. + +WARNING: These APIs are experimental and may change without notice. +""" + +from collections.abc import AsyncIterator, Awaitable, Callable + +import anyio + +from mcp.shared.experimental.tasks.helpers import is_terminal +from mcp.types import GetTaskResult + + +async def poll_until_terminal( + get_task: Callable[[str], Awaitable[GetTaskResult]], + task_id: str, + default_interval_ms: int = 500, +) -> AsyncIterator[GetTaskResult]: + """ + Poll a task until it reaches terminal status. + + This is a generic utility that works for both client→server and server→client + polling. The caller provides the get_task function appropriate for their direction. + + Args: + get_task: Async function that takes task_id and returns GetTaskResult + task_id: The task to poll + default_interval_ms: Fallback poll interval if server doesn't specify + + Yields: + GetTaskResult for each poll + """ + while True: + status = await get_task(task_id) + yield status + + if is_terminal(status.status): + break + + interval_ms = status.pollInterval if status.pollInterval is not None else default_interval_ms + await anyio.sleep(interval_ms / 1000) diff --git a/src/mcp/shared/experimental/tasks/resolver.py b/src/mcp/shared/experimental/tasks/resolver.py new file mode 100644 index 0000000000..f27425b2c6 --- /dev/null +++ b/src/mcp/shared/experimental/tasks/resolver.py @@ -0,0 +1,60 @@ +""" +Resolver - An anyio-compatible future-like object for async result passing. + +This provides a simple way to pass a result (or exception) from one coroutine +to another without depending on asyncio.Future. +""" + +from typing import Generic, TypeVar, cast + +import anyio + +T = TypeVar("T") + + +class Resolver(Generic[T]): + """ + A simple resolver for passing results between coroutines. + + Unlike asyncio.Future, this works with any anyio-compatible async backend. + + Usage: + resolver: Resolver[str] = Resolver() + + # In one coroutine: + resolver.set_result("hello") + + # In another coroutine: + result = await resolver.wait() # returns "hello" + """ + + def __init__(self) -> None: + self._event = anyio.Event() + self._value: T | None = None + self._exception: BaseException | None = None + + def set_result(self, value: T) -> None: + """Set the result value and wake up waiters.""" + if self._event.is_set(): + raise RuntimeError("Resolver already completed") + self._value = value + self._event.set() + + def set_exception(self, exc: BaseException) -> None: + """Set an exception and wake up waiters.""" + if self._event.is_set(): + raise RuntimeError("Resolver already completed") + self._exception = exc + self._event.set() + + async def wait(self) -> T: + """Wait for the result and return it, or raise the exception.""" + await self._event.wait() + if self._exception is not None: + raise self._exception + # If we reach here, set_result() was called, so _value is set + return cast(T, self._value) + + def done(self) -> bool: + """Return True if the resolver has been completed.""" + return self._event.is_set() diff --git a/src/mcp/shared/experimental/tasks/store.py b/src/mcp/shared/experimental/tasks/store.py new file mode 100644 index 0000000000..71fb4511b8 --- /dev/null +++ b/src/mcp/shared/experimental/tasks/store.py @@ -0,0 +1,156 @@ +""" +TaskStore - Abstract interface for task state storage. +""" + +from abc import ABC, abstractmethod + +from mcp.types import Result, Task, TaskMetadata, TaskStatus + + +class TaskStore(ABC): + """ + Abstract interface for task state storage. + + This is a pure storage interface - it doesn't manage execution. + Implementations can use in-memory storage, databases, Redis, etc. + + All methods are async to support various backends. + """ + + @abstractmethod + async def create_task( + self, + metadata: TaskMetadata, + task_id: str | None = None, + ) -> Task: + """ + Create a new task. + + Args: + metadata: Task metadata (ttl, etc.) + task_id: Optional task ID. If None, implementation should generate one. + + Returns: + The created Task with status="working" + + Raises: + ValueError: If task_id already exists + """ + + @abstractmethod + async def get_task(self, task_id: str) -> Task | None: + """ + Get a task by ID. + + Args: + task_id: The task identifier + + Returns: + The Task, or None if not found + """ + + @abstractmethod + async def update_task( + self, + task_id: str, + status: TaskStatus | None = None, + status_message: str | None = None, + ) -> Task: + """ + Update a task's status and/or message. + + Args: + task_id: The task identifier + status: New status (if changing) + status_message: New status message (if changing) + + Returns: + The updated Task + + Raises: + ValueError: If task not found + ValueError: If attempting to transition from a terminal status + (completed, failed, cancelled). Per spec, terminal states + MUST NOT transition to any other status. + """ + + @abstractmethod + async def store_result(self, task_id: str, result: Result) -> None: + """ + Store the result for a task. + + Args: + task_id: The task identifier + result: The result to store + + Raises: + ValueError: If task not found + """ + + @abstractmethod + async def get_result(self, task_id: str) -> Result | None: + """ + Get the stored result for a task. + + Args: + task_id: The task identifier + + Returns: + The stored Result, or None if not available + """ + + @abstractmethod + async def list_tasks( + self, + cursor: str | None = None, + ) -> tuple[list[Task], str | None]: + """ + List tasks with pagination. + + Args: + cursor: Optional cursor for pagination + + Returns: + Tuple of (tasks, next_cursor). next_cursor is None if no more pages. + """ + + @abstractmethod + async def delete_task(self, task_id: str) -> bool: + """ + Delete a task. + + Args: + task_id: The task identifier + + Returns: + True if deleted, False if not found + """ + + @abstractmethod + async def wait_for_update(self, task_id: str) -> None: + """ + Wait until the task status changes. + + This blocks until either: + 1. The task status changes + 2. The wait is cancelled + + Used by tasks/result to wait for task completion or status changes. + + Args: + task_id: The task identifier + + Raises: + ValueError: If task not found + """ + + @abstractmethod + async def notify_update(self, task_id: str) -> None: + """ + Signal that a task has been updated. + + This wakes up any coroutines waiting in wait_for_update(). + + Args: + task_id: The task identifier + """ diff --git a/src/mcp/shared/memory.py b/src/mcp/shared/memory.py index abf87a3aae..c7c6dbabc2 100644 --- a/src/mcp/shared/memory.py +++ b/src/mcp/shared/memory.py @@ -2,35 +2,26 @@ In-memory transports """ +from __future__ import annotations + from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from datetime import timedelta from typing import Any import anyio from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream import mcp.types as types -from mcp.client.session import ( - ClientSession, - ListRootsFnT, - LoggingFnT, - MessageHandlerFnT, - SamplingFnT, -) +from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT from mcp.server import Server -from mcp.types import JSONRPCMessage +from mcp.server.fastmcp import FastMCP +from mcp.shared.message import SessionMessage -MessageStream = tuple[ - MemoryObjectReceiveStream[JSONRPCMessage | Exception], - MemoryObjectSendStream[JSONRPCMessage], -] +MessageStream = tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]] @asynccontextmanager -async def create_client_server_memory_streams() -> ( - AsyncGenerator[tuple[MessageStream, MessageStream], None] -): +async def create_client_server_memory_streams() -> AsyncGenerator[tuple[MessageStream, MessageStream], None]: """ Creates a pair of bidirectional memory streams for client-server communication. @@ -39,12 +30,8 @@ async def create_client_server_memory_streams() -> ( (read_stream, write_stream) """ # Create streams for both directions - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[ - JSONRPCMessage | Exception - ](1) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[ - JSONRPCMessage | Exception - ](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) client_streams = (server_to_client_receive, client_to_server_send) server_streams = (client_to_server_receive, server_to_client_send) @@ -60,20 +47,24 @@ async def create_client_server_memory_streams() -> ( @asynccontextmanager async def create_connected_server_and_client_session( - server: Server[Any], - read_timeout_seconds: timedelta | None = None, + server: Server[Any] | FastMCP, + read_timeout_seconds: float | None = None, sampling_callback: SamplingFnT | None = None, list_roots_callback: ListRootsFnT | None = None, logging_callback: LoggingFnT | None = None, message_handler: MessageHandlerFnT | None = None, client_info: types.Implementation | None = None, raise_exceptions: bool = False, + elicitation_callback: ElicitationFnT | None = None, ) -> AsyncGenerator[ClientSession, None]: """Creates a ClientSession that is connected to a running MCP server.""" - async with create_client_server_memory_streams() as ( - client_streams, - server_streams, - ): + + # TODO(Marcelo): we should have a proper `Client` that can use this "in-memory transport", + # and we should expose a method in the `FastMCP` so we don't access a private attribute. + if isinstance(server, FastMCP): # pragma: no cover + server = server._mcp_server # type: ignore[reportPrivateUsage] + + async with create_client_server_memory_streams() as (client_streams, server_streams): client_read, client_write = client_streams server_read, server_write = server_streams @@ -98,8 +89,9 @@ async def create_connected_server_and_client_session( logging_callback=logging_callback, message_handler=message_handler, client_info=client_info, + elicitation_callback=elicitation_callback, ) as client_session: await client_session.initialize() yield client_session - finally: + finally: # pragma: no cover tg.cancel_scope.cancel() diff --git a/src/mcp/shared/message.py b/src/mcp/shared/message.py new file mode 100644 index 0000000000..81503eaaa7 --- /dev/null +++ b/src/mcp/shared/message.py @@ -0,0 +1,50 @@ +""" +Message wrapper with metadata support. + +This module defines a wrapper type that combines JSONRPCMessage with metadata +to support transport-specific features like resumability. +""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from mcp.types import JSONRPCMessage, RequestId + +ResumptionToken = str + +ResumptionTokenUpdateCallback = Callable[[ResumptionToken], Awaitable[None]] + +# Callback type for closing SSE streams without terminating +CloseSSEStreamCallback = Callable[[], Awaitable[None]] + + +@dataclass +class ClientMessageMetadata: + """Metadata specific to client messages.""" + + resumption_token: ResumptionToken | None = None + on_resumption_token_update: Callable[[ResumptionToken], Awaitable[None]] | None = None + + +@dataclass +class ServerMessageMetadata: + """Metadata specific to server messages.""" + + related_request_id: RequestId | None = None + # Request-specific context (e.g., headers, auth info) + request_context: object | None = None + # Callback to close SSE stream for the current request without terminating + close_sse_stream: CloseSSEStreamCallback | None = None + # Callback to close the standalone GET SSE stream (for unsolicited notifications) + close_standalone_sse_stream: CloseSSEStreamCallback | None = None + + +MessageMetadata = ClientMessageMetadata | ServerMessageMetadata | None + + +@dataclass +class SessionMessage: + """A message with specific metadata for transport-specific features.""" + + message: JSONRPCMessage + metadata: MessageMetadata = None diff --git a/src/mcp/shared/metadata_utils.py b/src/mcp/shared/metadata_utils.py new file mode 100644 index 0000000000..e3f49daf48 --- /dev/null +++ b/src/mcp/shared/metadata_utils.py @@ -0,0 +1,45 @@ +"""Utility functions for working with metadata in MCP types. + +These utilities are primarily intended for client-side usage to properly display +human-readable names in user interfaces in a spec compliant way. +""" + +from mcp.types import Implementation, Prompt, Resource, ResourceTemplate, Tool + + +def get_display_name(obj: Tool | Resource | Prompt | ResourceTemplate | Implementation) -> str: + """ + Get the display name for an MCP object with proper precedence. + + This is a client-side utility function designed to help MCP clients display + human-readable names in their user interfaces. When servers provide a 'title' + field, it should be preferred over the programmatic 'name' field for display. + + For tools: title > annotations.title > name + For other objects: title > name + + Example: + # In a client displaying available tools + tools = await session.list_tools() + for tool in tools.tools: + display_name = get_display_name(tool) + print(f"Available tool: {display_name}") + + Args: + obj: An MCP object with name and optional title fields + + Returns: + The display name to use for UI presentation + """ + if isinstance(obj, Tool): + # Tools have special precedence: title > annotations.title > name + if hasattr(obj, "title") and obj.title is not None: + return obj.title + if obj.annotations and hasattr(obj.annotations, "title") and obj.annotations.title is not None: + return obj.annotations.title + return obj.name + else: + # All other objects: title > name + if hasattr(obj, "title") and obj.title is not None: + return obj.title + return obj.name diff --git a/src/mcp/shared/progress.py b/src/mcp/shared/progress.py index 52e0017d0a..a230c58b45 100644 --- a/src/mcp/shared/progress.py +++ b/src/mcp/shared/progress.py @@ -23,58 +23,32 @@ class Progress(BaseModel): @dataclass -class ProgressContext( - Generic[ - SendRequestT, - SendNotificationT, - SendResultT, - ReceiveRequestT, - ReceiveNotificationT, - ] -): - session: BaseSession[ - SendRequestT, - SendNotificationT, - SendResultT, - ReceiveRequestT, - ReceiveNotificationT, - ] +class ProgressContext(Generic[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT]): + session: BaseSession[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT] progress_token: ProgressToken total: float | None current: float = field(default=0.0, init=False) - async def progress(self, amount: float) -> None: + async def progress(self, amount: float, message: str | None = None) -> None: self.current += amount await self.session.send_progress_notification( - self.progress_token, self.current, total=self.total + self.progress_token, self.current, total=self.total, message=message ) @contextmanager def progress( ctx: RequestContext[ - BaseSession[ - SendRequestT, - SendNotificationT, - SendResultT, - ReceiveRequestT, - ReceiveNotificationT, - ], + BaseSession[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT], LifespanContextT, ], total: float | None = None, ) -> Generator[ - ProgressContext[ - SendRequestT, - SendNotificationT, - SendResultT, - ReceiveRequestT, - ReceiveNotificationT, - ], + ProgressContext[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT], None, ]: - if ctx.meta is None or ctx.meta.progressToken is None: + if ctx.meta is None or ctx.meta.progressToken is None: # pragma: no cover raise ValueError("No progress token provided") progress_ctx = ProgressContext(ctx.session, ctx.meta.progressToken, total) diff --git a/src/mcp/shared/response_router.py b/src/mcp/shared/response_router.py new file mode 100644 index 0000000000..31796157fe --- /dev/null +++ b/src/mcp/shared/response_router.py @@ -0,0 +1,63 @@ +""" +ResponseRouter - Protocol for pluggable response routing. + +This module defines a protocol for routing JSON-RPC responses to alternative +handlers before falling back to the default response stream mechanism. + +The primary use case is task-augmented requests: when a TaskSession enqueues +a request (like elicitation), the response needs to be routed back to the +waiting resolver instead of the normal response stream. + +Design: +- Protocol-based for testability and flexibility +- Returns bool to indicate if response was handled +- Supports both success responses and errors +""" + +from typing import Any, Protocol + +from mcp.types import ErrorData, RequestId + + +class ResponseRouter(Protocol): + """ + Protocol for routing responses to alternative handlers. + + Implementations check if they have a pending request for the given ID + and deliver the response/error to the appropriate handler. + + Example: + class TaskResultHandler(ResponseRouter): + def route_response(self, request_id, response): + resolver = self._pending_requests.pop(request_id, None) + if resolver: + resolver.set_result(response) + return True + return False + """ + + def route_response(self, request_id: RequestId, response: dict[str, Any]) -> bool: + """ + Try to route a response to a pending request handler. + + Args: + request_id: The JSON-RPC request ID from the response + response: The response result data + + Returns: + True if the response was handled, False otherwise + """ + ... # pragma: no cover + + def route_error(self, request_id: RequestId, error: ErrorData) -> bool: + """ + Try to route an error to a pending request handler. + + Args: + request_id: The JSON-RPC request ID from the error response + error: The error data + + Returns: + True if the error was handled, False otherwise + """ + ... # pragma: no cover diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index 05fd3ce37f..c807e291c4 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -1,19 +1,21 @@ import logging from collections.abc import Callable from contextlib import AsyncExitStack -from datetime import timedelta from types import TracebackType -from typing import Any, Generic, TypeVar +from typing import Any, Generic, Protocol, TypeVar import anyio -import anyio.lowlevel import httpx from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import BaseModel from typing_extensions import Self from mcp.shared.exceptions import McpError +from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage +from mcp.shared.response_router import ResponseRouter from mcp.types import ( + CONNECTION_CLOSED, + INVALID_PARAMS, CancelledNotification, ClientNotification, ClientRequest, @@ -24,6 +26,7 @@ JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, + ProgressNotification, RequestParams, ServerNotification, ServerRequest, @@ -35,13 +38,19 @@ SendNotificationT = TypeVar("SendNotificationT", ClientNotification, ServerNotification) ReceiveRequestT = TypeVar("ReceiveRequestT", ClientRequest, ServerRequest) ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel) -ReceiveNotificationT = TypeVar( - "ReceiveNotificationT", ClientNotification, ServerNotification -) +ReceiveNotificationT = TypeVar("ReceiveNotificationT", ClientNotification, ServerNotification) RequestId = str | int +class ProgressFnT(Protocol): + """Protocol for progress notification callbacks.""" + + async def __call__( + self, progress: float, total: float | None, message: str | None + ) -> None: ... # pragma: no branch + + class RequestResponder(Generic[ReceiveRequestT, SendResultT]): """Handles responding to MCP requests and manages request lifecycle. @@ -71,10 +80,12 @@ def __init__( ReceiveNotificationT ]""", on_complete: Callable[["RequestResponder[ReceiveRequestT, SendResultT]"], Any], + message_metadata: MessageMetadata = None, ) -> None: self.request_id = request_id self.request_meta = request_meta self.request = request + self.message_metadata = message_metadata self._session = session self._completed = False self._cancel_scope = anyio.CancelScope() @@ -96,11 +107,11 @@ def __exit__( ) -> None: """Exit the context manager, performing cleanup and notifying completion.""" try: - if self._completed: + if self._completed: # pragma: no branch self._on_complete(self) finally: self._entered = False - if not self._cancel_scope: + if not self._cancel_scope: # pragma: no cover raise RuntimeError("No active cancel scope") self._cancel_scope.__exit__(exc_type, exc_val, exc_tb) @@ -112,11 +123,11 @@ async def respond(self, response: SendResultT | ErrorData) -> None: RuntimeError: If not used within a context manager AssertionError: If request was already responded to """ - if not self._entered: + if not self._entered: # pragma: no cover raise RuntimeError("RequestResponder must be used as a context manager") assert not self._completed, "Request already responded to" - if not self.cancelled: + if not self.cancelled: # pragma: no branch self._completed = True await self._session._send_response( # type: ignore[reportPrivateUsage] @@ -125,9 +136,9 @@ async def respond(self, response: SendResultT | ErrorData) -> None: async def cancel(self) -> None: """Cancel this request and mark it as completed.""" - if not self._entered: + if not self._entered: # pragma: no cover raise RuntimeError("RequestResponder must be used as a context manager") - if not self._cancel_scope: + if not self._cancel_scope: # pragma: no cover raise RuntimeError("No active cancel scope") self._cancel_scope.cancel() @@ -139,11 +150,11 @@ async def cancel(self) -> None: ) @property - def in_flight(self) -> bool: + def in_flight(self) -> bool: # pragma: no cover return not self._completed and not self.cancelled @property - def cancelled(self) -> bool: + def cancelled(self) -> bool: # pragma: no cover return self._cancel_scope.cancel_called @@ -164,20 +175,20 @@ class BaseSession( messages when entered. """ - _response_streams: dict[ - RequestId, MemoryObjectSendStream[JSONRPCResponse | JSONRPCError] - ] + _response_streams: dict[RequestId, MemoryObjectSendStream[JSONRPCResponse | JSONRPCError]] _request_id: int _in_flight: dict[RequestId, RequestResponder[ReceiveRequestT, SendResultT]] + _progress_callbacks: dict[RequestId, ProgressFnT] + _response_routers: list["ResponseRouter"] def __init__( self, - read_stream: MemoryObjectReceiveStream[JSONRPCMessage | Exception], - write_stream: MemoryObjectSendStream[JSONRPCMessage], + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], + write_stream: MemoryObjectSendStream[SessionMessage], receive_request_type: type[ReceiveRequestT], receive_notification_type: type[ReceiveNotificationT], # If none, reading will never time out - read_timeout_seconds: timedelta | None = None, + read_timeout_seconds: float | None = None, ) -> None: self._read_stream = read_stream self._write_stream = write_stream @@ -185,11 +196,27 @@ def __init__( self._request_id = 0 self._receive_request_type = receive_request_type self._receive_notification_type = receive_notification_type - self._read_timeout_seconds = read_timeout_seconds + self._session_read_timeout_seconds = read_timeout_seconds self._in_flight = {} - + self._progress_callbacks = {} + self._response_routers = [] self._exit_stack = AsyncExitStack() + def add_response_router(self, router: ResponseRouter) -> None: + """ + Register a response router to handle responses for non-standard requests. + + Response routers are checked in order before falling back to the default + response stream mechanism. This is used by TaskResultHandler to route + responses for queued task requests back to their resolvers. + + WARNING: This is an experimental API that may change without notice. + + Args: + router: A ResponseRouter implementation + """ + self._response_routers.append(router) + async def __aenter__(self) -> Self: self._task_group = anyio.create_task_group() await self._task_group.__aenter__() @@ -213,155 +240,281 @@ async def send_request( self, request: SendRequestT, result_type: type[ReceiveResultT], + request_read_timeout_seconds: float | None = None, + metadata: MessageMetadata = None, + progress_callback: ProgressFnT | None = None, ) -> ReceiveResultT: """ Sends a request and wait for a response. Raises an McpError if the - response contains an error. + response contains an error. If a request read timeout is provided, it + will take precedence over the session read timeout. Do not use this method to emit notifications! Use send_notification() instead. """ - request_id = self._request_id self._request_id = request_id + 1 - response_stream, response_stream_reader = anyio.create_memory_object_stream[ - JSONRPCResponse | JSONRPCError - ](1) + response_stream, response_stream_reader = anyio.create_memory_object_stream[JSONRPCResponse | JSONRPCError](1) self._response_streams[request_id] = response_stream - self._exit_stack.push_async_callback(lambda: response_stream.aclose()) - self._exit_stack.push_async_callback(lambda: response_stream_reader.aclose()) - - jsonrpc_request = JSONRPCRequest( - jsonrpc="2.0", - id=request_id, - **request.model_dump(by_alias=True, mode="json", exclude_none=True), - ) - - # TODO: Support progress callbacks - - await self._write_stream.send(JSONRPCMessage(jsonrpc_request)) + # Set up progress token if progress callback is provided + request_data = request.model_dump(by_alias=True, mode="json", exclude_none=True) + if progress_callback is not None: # pragma: no cover + # Use request_id as progress token + if "params" not in request_data: + request_data["params"] = {} + if "_meta" not in request_data["params"]: # pragma: no branch + request_data["params"]["_meta"] = {} + request_data["params"]["_meta"]["progressToken"] = request_id + # Store the callback for this request + self._progress_callbacks[request_id] = progress_callback try: - with anyio.fail_after( - None - if self._read_timeout_seconds is None - else self._read_timeout_seconds.total_seconds() - ): - response_or_error = await response_stream_reader.receive() - except TimeoutError: - raise McpError( - ErrorData( - code=httpx.codes.REQUEST_TIMEOUT, - message=( - f"Timed out while waiting for response to " - f"{request.__class__.__name__}. Waited " - f"{self._read_timeout_seconds} seconds." - ), - ) + jsonrpc_request = JSONRPCRequest( + jsonrpc="2.0", + id=request_id, + **request_data, ) - if isinstance(response_or_error, JSONRPCError): - raise McpError(response_or_error.error) - else: - return result_type.model_validate(response_or_error.result) + await self._write_stream.send(SessionMessage(message=JSONRPCMessage(jsonrpc_request), metadata=metadata)) + + # request read timeout takes precedence over session read timeout + timeout = None + if request_read_timeout_seconds is not None: # pragma: no cover + timeout = request_read_timeout_seconds + elif self._session_read_timeout_seconds is not None: # pragma: no cover + timeout = self._session_read_timeout_seconds + + try: + with anyio.fail_after(timeout): + response_or_error = await response_stream_reader.receive() + except TimeoutError: + raise McpError( + ErrorData( + code=httpx.codes.REQUEST_TIMEOUT, + message=( + f"Timed out while waiting for response to " + f"{request.__class__.__name__}. Waited " + f"{timeout} seconds." + ), + ) + ) + + if isinstance(response_or_error, JSONRPCError): + raise McpError(response_or_error.error) + else: + return result_type.model_validate(response_or_error.result) - async def send_notification(self, notification: SendNotificationT) -> None: + finally: + self._response_streams.pop(request_id, None) + self._progress_callbacks.pop(request_id, None) + await response_stream.aclose() + await response_stream_reader.aclose() + + async def send_notification( + self, + notification: SendNotificationT, + related_request_id: RequestId | None = None, + ) -> None: """ Emits a notification, which is a one-way message that does not expect a response. """ + # Some transport implementations may need to set the related_request_id + # to attribute to the notifications to the request that triggered them. jsonrpc_notification = JSONRPCNotification( jsonrpc="2.0", **notification.model_dump(by_alias=True, mode="json", exclude_none=True), ) + session_message = SessionMessage( # pragma: no cover + message=JSONRPCMessage(jsonrpc_notification), + metadata=ServerMessageMetadata(related_request_id=related_request_id) if related_request_id else None, + ) + await self._write_stream.send(session_message) - await self._write_stream.send(JSONRPCMessage(jsonrpc_notification)) - - async def _send_response( - self, request_id: RequestId, response: SendResultT | ErrorData - ) -> None: + async def _send_response(self, request_id: RequestId, response: SendResultT | ErrorData) -> None: if isinstance(response, ErrorData): jsonrpc_error = JSONRPCError(jsonrpc="2.0", id=request_id, error=response) - await self._write_stream.send(JSONRPCMessage(jsonrpc_error)) + session_message = SessionMessage(message=JSONRPCMessage(jsonrpc_error)) + await self._write_stream.send(session_message) else: jsonrpc_response = JSONRPCResponse( jsonrpc="2.0", id=request_id, - result=response.model_dump( - by_alias=True, mode="json", exclude_none=True - ), + result=response.model_dump(by_alias=True, mode="json", exclude_none=True), ) - await self._write_stream.send(JSONRPCMessage(jsonrpc_response)) + session_message = SessionMessage(message=JSONRPCMessage(jsonrpc_response)) + await self._write_stream.send(session_message) async def _receive_loop(self) -> None: async with ( self._read_stream, self._write_stream, ): - async for message in self._read_stream: - if isinstance(message, Exception): - await self._handle_incoming(message) - elif isinstance(message.root, JSONRPCRequest): - validated_request = self._receive_request_type.model_validate( - message.root.model_dump( - by_alias=True, mode="json", exclude_none=True - ) - ) + try: + async for message in self._read_stream: + if isinstance(message, Exception): # pragma: no cover + await self._handle_incoming(message) + elif isinstance(message.message.root, JSONRPCRequest): + try: + validated_request = self._receive_request_type.model_validate( + message.message.root.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + responder = RequestResponder( + request_id=message.message.root.id, + request_meta=validated_request.root.params.meta + if validated_request.root.params + else None, + request=validated_request, + session=self, + on_complete=lambda r: self._in_flight.pop(r.request_id, None), + message_metadata=message.metadata, + ) + self._in_flight[responder.request_id] = responder + await self._received_request(responder) + + if not responder._completed: # type: ignore[reportPrivateUsage] + await self._handle_incoming(responder) + except Exception as e: + # For request validation errors, send a proper JSON-RPC error + # response instead of crashing the server + logging.warning(f"Failed to validate request: {e}") + logging.debug(f"Message that failed validation: {message.message.root}") + error_response = JSONRPCError( + jsonrpc="2.0", + id=message.message.root.id, + error=ErrorData( + code=INVALID_PARAMS, + message="Invalid request parameters", + data="", + ), + ) + session_message = SessionMessage(message=JSONRPCMessage(error_response)) + await self._write_stream.send(session_message) - responder = RequestResponder( - request_id=message.root.id, - request_meta=validated_request.root.params.meta - if validated_request.root.params - else None, - request=validated_request, - session=self, - on_complete=lambda r: self._in_flight.pop(r.request_id, None), - ) + elif isinstance(message.message.root, JSONRPCNotification): + try: + notification = self._receive_notification_type.model_validate( + message.message.root.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + # Handle cancellation notifications + if isinstance(notification.root, CancelledNotification): + cancelled_id = notification.root.params.requestId + if cancelled_id in self._in_flight: # pragma: no branch + await self._in_flight[cancelled_id].cancel() + else: + # Handle progress notifications callback + if isinstance(notification.root, ProgressNotification): # pragma: no cover + progress_token = notification.root.params.progressToken + # If there is a progress callback for this token, + # call it with the progress information + if progress_token in self._progress_callbacks: + callback = self._progress_callbacks[progress_token] + try: + await callback( + notification.root.params.progress, + notification.root.params.total, + notification.root.params.message, + ) + except Exception as e: + logging.error( + "Progress callback raised an exception: %s", + e, + ) + await self._received_notification(notification) + await self._handle_incoming(notification) + except Exception as e: # pragma: no cover + # For other validation errors, log and continue + logging.warning( + f"Failed to validate notification: {e}. Message was: {message.message.root}" + ) + else: # Response or error + await self._handle_response(message) + + except anyio.ClosedResourceError: + # This is expected when the client disconnects abruptly. + # Without this handler, the exception would propagate up and + # crash the server's task group. + logging.debug("Read stream closed by client") # pragma: no cover + except Exception as e: # pragma: no cover + # Other exceptions are not expected and should be logged. We purposefully + # catch all exceptions here to avoid crashing the server. + logging.exception(f"Unhandled exception in receive loop: {e}") + finally: + # after the read stream is closed, we need to send errors + # to any pending requests + for id, stream in self._response_streams.items(): + error = ErrorData(code=CONNECTION_CLOSED, message="Connection closed") + try: + await stream.send(JSONRPCError(jsonrpc="2.0", id=id, error=error)) + await stream.aclose() + except Exception: # pragma: no cover + # Stream might already be closed + pass + self._response_streams.clear() + + def _normalize_request_id(self, response_id: RequestId) -> RequestId: + """ + Normalize a response ID to match how request IDs are stored. - self._in_flight[responder.request_id] = responder - await self._received_request(responder) + Since the client always sends integer IDs, we normalize string IDs + to integers when possible. This matches the TypeScript SDK approach: + https://github.com/modelcontextprotocol/typescript-sdk/blob/a606fb17909ea454e83aab14c73f14ea45c04448/src/shared/protocol.ts#L861 - if not responder._completed: # type: ignore[reportPrivateUsage] - await self._handle_incoming(responder) + Args: + response_id: The response ID from the incoming message. - elif isinstance(message.root, JSONRPCNotification): - try: - notification = self._receive_notification_type.model_validate( - message.root.model_dump( - by_alias=True, mode="json", exclude_none=True - ) - ) - # Handle cancellation notifications - if isinstance(notification.root, CancelledNotification): - cancelled_id = notification.root.params.requestId - if cancelled_id in self._in_flight: - await self._in_flight[cancelled_id].cancel() - else: - await self._received_notification(notification) - await self._handle_incoming(notification) - except Exception as e: - # For other validation errors, log and continue - logging.warning( - f"Failed to validate notification: {e}. " - f"Message was: {message.root}" - ) - else: # Response or error - stream = self._response_streams.pop(message.root.id, None) - if stream: - await stream.send(message.root) - else: - await self._handle_incoming( - RuntimeError( - "Received response with an unknown " - f"request ID: {message}" - ) - ) + Returns: + The normalized ID (int if possible, otherwise original value). + """ + if isinstance(response_id, str): + try: + return int(response_id) + except ValueError: + logging.warning(f"Response ID {response_id!r} cannot be normalized to match pending requests") + return response_id + + async def _handle_response(self, message: SessionMessage) -> None: + """ + Handle an incoming response or error message. - async def _received_request( - self, responder: RequestResponder[ReceiveRequestT, SendResultT] - ) -> None: + Checks response routers first (e.g., for task-related responses), + then falls back to the normal response stream mechanism. + """ + root = message.message.root + + # This check is always true at runtime: the caller (_receive_loop) only invokes + # this method in the else branch after checking for JSONRPCRequest and + # JSONRPCNotification. However, the type checker can't infer this from the + # method signature, so we need this guard for type narrowing. + if not isinstance(root, JSONRPCResponse | JSONRPCError): + return # pragma: no cover + + # Normalize response ID to handle type mismatches (e.g., "0" vs 0) + response_id = self._normalize_request_id(root.id) + + # First, check response routers (e.g., TaskResultHandler) + if isinstance(root, JSONRPCError): + # Route error to routers + for router in self._response_routers: + if router.route_error(response_id, root.error): + return # Handled + else: + # Route success response to routers + response_data: dict[str, Any] = root.result or {} + for router in self._response_routers: + if router.route_response(response_id, response_data): + return # Handled + + # Fall back to normal response streams + stream = self._response_streams.pop(response_id, None) + if stream: # pragma: no cover + await stream.send(root) + else: # pragma: no cover + await self._handle_incoming(RuntimeError(f"Received response with an unknown request ID: {message}")) + + async def _received_request(self, responder: RequestResponder[ReceiveRequestT, SendResultT]) -> None: """ Can be overridden by subclasses to handle a request without needing to listen on the message stream. @@ -377,7 +530,11 @@ async def _received_notification(self, notification: ReceiveNotificationT) -> No """ async def send_progress_notification( - self, progress_token: str | int, progress: float, total: float | None = None + self, + progress_token: str | int, + progress: float, + total: float | None = None, + message: str | None = None, ) -> None: """ Sends a progress notification for a request that is currently being @@ -386,9 +543,7 @@ async def send_progress_notification( async def _handle_incoming( self, - req: RequestResponder[ReceiveRequestT, SendResultT] - | ReceiveNotificationT - | Exception, + req: RequestResponder[ReceiveRequestT, SendResultT] | ReceiveNotificationT | Exception, ) -> None: """A generic handler for incoming messages. Overwritten by subclasses.""" - pass + pass # pragma: no cover diff --git a/src/mcp/shared/tool_name_validation.py b/src/mcp/shared/tool_name_validation.py new file mode 100644 index 0000000000..f35efa5a61 --- /dev/null +++ b/src/mcp/shared/tool_name_validation.py @@ -0,0 +1,129 @@ +"""Tool name validation utilities according to SEP-986. + +Tool names SHOULD be between 1 and 128 characters in length (inclusive). +Tool names are case-sensitive. +Allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), +digits (0-9), underscore (_), dash (-), and dot (.). +Tool names SHOULD NOT contain spaces, commas, or other special characters. + +See: https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +# Regular expression for valid tool names according to SEP-986 specification +TOOL_NAME_REGEX = re.compile(r"^[A-Za-z0-9._-]{1,128}$") + +# SEP reference URL for warning messages +SEP_986_URL = "https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names" + + +@dataclass +class ToolNameValidationResult: + """Result of tool name validation. + + Attributes: + is_valid: Whether the tool name conforms to SEP-986 requirements. + warnings: List of warning messages for non-conforming aspects. + """ + + is_valid: bool + warnings: list[str] = field(default_factory=lambda: []) + + +def validate_tool_name(name: str) -> ToolNameValidationResult: + """Validate a tool name according to the SEP-986 specification. + + Args: + name: The tool name to validate. + + Returns: + ToolNameValidationResult containing validation status and any warnings. + """ + warnings: list[str] = [] + + # Check for empty name + if not name: + return ToolNameValidationResult( + is_valid=False, + warnings=["Tool name cannot be empty"], + ) + + # Check length + if len(name) > 128: + return ToolNameValidationResult( + is_valid=False, + warnings=[f"Tool name exceeds maximum length of 128 characters (current: {len(name)})"], + ) + + # Check for problematic patterns (warnings, not validation failures) + if " " in name: + warnings.append("Tool name contains spaces, which may cause parsing issues") + + if "," in name: + warnings.append("Tool name contains commas, which may cause parsing issues") + + # Check for potentially confusing leading/trailing characters + if name.startswith("-") or name.endswith("-"): + warnings.append("Tool name starts or ends with a dash, which may cause parsing issues in some contexts") + + if name.startswith(".") or name.endswith("."): + warnings.append("Tool name starts or ends with a dot, which may cause parsing issues in some contexts") + + # Check for invalid characters + if not TOOL_NAME_REGEX.match(name): + # Find all invalid characters (unique, preserving order) + invalid_chars: list[str] = [] + seen: set[str] = set() + for char in name: + if not re.match(r"[A-Za-z0-9._-]", char) and char not in seen: + invalid_chars.append(char) + seen.add(char) + + warnings.append(f"Tool name contains invalid characters: {', '.join(repr(c) for c in invalid_chars)}") + warnings.append("Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)") + + return ToolNameValidationResult(is_valid=False, warnings=warnings) + + return ToolNameValidationResult(is_valid=True, warnings=warnings) + + +def issue_tool_name_warning(name: str, warnings: list[str]) -> None: + """Log warnings for non-conforming tool names. + + Args: + name: The tool name that triggered the warnings. + warnings: List of warning messages to log. + """ + if not warnings: + return + + logger.warning(f'Tool name validation warning for "{name}":') + for warning in warnings: + logger.warning(f" - {warning}") + logger.warning("Tool registration will proceed, but this may cause compatibility issues.") + logger.warning("Consider updating the tool name to conform to the MCP tool naming standard.") + logger.warning(f"See SEP-986 ({SEP_986_URL}) for more details.") + + +def validate_and_warn_tool_name(name: str) -> bool: + """Validate a tool name and issue warnings for non-conforming names. + + This is the primary entry point for tool name validation. It validates + the name and logs any warnings via the logging module. + + Args: + name: The tool name to validate. + + Returns: + True if the name is valid, False otherwise. + """ + result = validate_tool_name(name) + issue_tool_name_warning(name, result.warnings) + return result.is_valid diff --git a/src/mcp/shared/version.py b/src/mcp/shared/version.py index 8fd13b9926..d2a1e462d4 100644 --- a/src/mcp/shared/version.py +++ b/src/mcp/shared/version.py @@ -1,3 +1,3 @@ from mcp.types import LATEST_PROTOCOL_VERSION -SUPPORTED_PROTOCOL_VERSIONS: tuple[int, str] = (1, LATEST_PROTOCOL_VERSION) +SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", "2025-03-26", "2025-06-18", LATEST_PROTOCOL_VERSION] diff --git a/src/mcp/types.py b/src/mcp/types.py index bd71d51f0f..654c00660b 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -1,15 +1,10 @@ from collections.abc import Callable -from typing import ( - Annotated, - Any, - Generic, - Literal, - TypeAlias, - TypeVar, -) +from datetime import datetime +from typing import Annotated, Any, Final, Generic, Literal, TypeAlias, TypeVar from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel from pydantic.networks import AnyUrl, UrlConstraints +from typing_extensions import deprecated """ Model Context Protocol bindings for Python @@ -29,14 +24,39 @@ not separate types in the schema. """ -LATEST_PROTOCOL_VERSION = "2024-11-05" +LATEST_PROTOCOL_VERSION = "2025-11-25" + +""" +The default negotiated version of the Model Context Protocol when no version is specified. +We need this to satisfy the MCP specification, which requires the server to assume a +specific version if none is provided by the client. See section "Protocol Version Header" at +https://modelcontextprotocol.io/specification +""" +DEFAULT_NEGOTIATED_VERSION = "2025-03-26" ProgressToken = str | int Cursor = str Role = Literal["user", "assistant"] -RequestId = str | int +RequestId = Annotated[int, Field(strict=True)] | str AnyFunction: TypeAlias = Callable[..., Any] +TaskExecutionMode = Literal["forbidden", "optional", "required"] +TASK_FORBIDDEN: Final[Literal["forbidden"]] = "forbidden" +TASK_OPTIONAL: Final[Literal["optional"]] = "optional" +TASK_REQUIRED: Final[Literal["required"]] = "required" + + +class TaskMetadata(BaseModel): + """ + Metadata for augmenting a request with task execution. + Include this in the `task` field of the request parameters. + """ + + model_config = ConfigDict(extra="allow") + + ttl: Annotated[int, Field(strict=True)] | None = None + """Requested duration in milliseconds to retain task from creation.""" + class RequestParams(BaseModel): class Meta(BaseModel): @@ -50,24 +70,40 @@ class Meta(BaseModel): model_config = ConfigDict(extra="allow") + task: TaskMetadata | None = None + """ + If specified, the caller is requesting task-augmented execution for this request. + The request will return a CreateTaskResult immediately, and the actual result can be + retrieved later via tasks/result. + + Task augmentation is subject to capability negotiation - receivers MUST declare support + for task augmentation of specific request types in their capabilities. + """ + meta: Meta | None = Field(alias="_meta", default=None) +class PaginatedRequestParams(RequestParams): + cursor: Cursor | None = None + """ + An opaque token representing the current pagination position. + If provided, the server should return results starting after this cursor. + """ + + class NotificationParams(BaseModel): class Meta(BaseModel): model_config = ConfigDict(extra="allow") meta: Meta | None = Field(alias="_meta", default=None) """ - This parameter name is reserved by MCP to allow clients and servers to attach - additional metadata to their notifications. + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. """ RequestParamsT = TypeVar("RequestParamsT", bound=RequestParams | dict[str, Any] | None) -NotificationParamsT = TypeVar( - "NotificationParamsT", bound=NotificationParams | dict[str, Any] | None -) +NotificationParamsT = TypeVar("NotificationParamsT", bound=NotificationParams | dict[str, Any] | None) MethodT = TypeVar("MethodT", bound=str) @@ -79,12 +115,11 @@ class Request(BaseModel, Generic[RequestParamsT, MethodT]): model_config = ConfigDict(extra="allow") -class PaginatedRequest(Request[RequestParamsT, MethodT]): - cursor: Cursor | None = None - """ - An opaque token representing the current pagination position. - If provided, the server should return results starting after this cursor. - """ +class PaginatedRequest(Request[PaginatedRequestParams | None, MethodT], Generic[MethodT]): + """Base class for paginated requests, + matching the schema's PaginatedRequest interface.""" + + params: PaginatedRequestParams | None = None class Notification(BaseModel, Generic[NotificationParamsT, MethodT]): @@ -98,13 +133,12 @@ class Notification(BaseModel, Generic[NotificationParamsT, MethodT]): class Result(BaseModel): """Base class for JSON-RPC results.""" - model_config = ConfigDict(extra="allow") - meta: dict[str, Any] | None = Field(alias="_meta", default=None) """ - This result property is reserved by the protocol to allow clients and servers to - attach additional metadata to their responses. + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. """ + model_config = ConfigDict(extra="allow") class PaginatedResult(Result): @@ -140,6 +174,14 @@ class JSONRPCResponse(BaseModel): model_config = ConfigDict(extra="allow") +# MCP-specific error codes in the range [-32000, -32099] +URL_ELICITATION_REQUIRED = -32042 +"""Error code indicating that a URL mode elicitation is required before the request can be processed.""" + +# SDK error codes +CONNECTION_CLOSED = -32000 +# REQUEST_TIMEOUT = -32001 # the typescript sdk uses this + # Standard JSON-RPC error codes PARSE_ERROR = -32700 INVALID_REQUEST = -32600 @@ -178,9 +220,7 @@ class JSONRPCError(BaseModel): model_config = ConfigDict(extra="allow") -class JSONRPCMessage( - RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError] -): +class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError]): pass @@ -188,11 +228,49 @@ class EmptyResult(Result): """A response that indicates success but carries no data.""" -class Implementation(BaseModel): - """Describes the name and version of an MCP implementation.""" +class BaseMetadata(BaseModel): + """Base class for entities with name and optional title fields.""" name: str + """The programmatic name of the entity.""" + + title: str | None = None + """ + Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + even by those unfamiliar with domain-specific terminology. + + If not provided, the name should be used for display (except for Tool, + where `annotations.title` should be given precedence over using `name`, + if present). + """ + + +class Icon(BaseModel): + """An icon for display in user interfaces.""" + + src: str + """URL or data URI for the icon.""" + + mimeType: str | None = None + """Optional MIME type for the icon.""" + + sizes: list[str] | None = None + """Optional list of strings specifying icon dimensions (e.g., ["48x48", "96x96"]).""" + + model_config = ConfigDict(extra="allow") + + +class Implementation(BaseMetadata): + """Describes the name and version of an MCP implementation.""" + version: str + + websiteUrl: str | None = None + """An optional URL of the website for this implementation.""" + + icons: list[Icon] | None = None + """An optional list of icons for this implementation.""" + model_config = ConfigDict(extra="allow") @@ -204,11 +282,137 @@ class RootsCapability(BaseModel): model_config = ConfigDict(extra="allow") +class SamplingContextCapability(BaseModel): + """ + Capability for context inclusion during sampling. + + Indicates support for non-'none' values in the includeContext parameter. + SOFT-DEPRECATED: New implementations should use tools parameter instead. + """ + + model_config = ConfigDict(extra="allow") + + +class SamplingToolsCapability(BaseModel): + """ + Capability indicating support for tool calling during sampling. + + When present in ClientCapabilities.sampling, indicates that the client + supports the tools and toolChoice parameters in sampling requests. + """ + + model_config = ConfigDict(extra="allow") + + +class FormElicitationCapability(BaseModel): + """Capability for form mode elicitation.""" + + model_config = ConfigDict(extra="allow") + + +class UrlElicitationCapability(BaseModel): + """Capability for URL mode elicitation.""" + + model_config = ConfigDict(extra="allow") + + +class ElicitationCapability(BaseModel): + """Capability for elicitation operations. + + Clients must support at least one mode (form or url). + """ + + form: FormElicitationCapability | None = None + """Present if the client supports form mode elicitation.""" + + url: UrlElicitationCapability | None = None + """Present if the client supports URL mode elicitation.""" + + model_config = ConfigDict(extra="allow") + + class SamplingCapability(BaseModel): - """Capability for logging operations.""" + """ + Sampling capability structure, allowing fine-grained capability advertisement. + """ + + context: SamplingContextCapability | None = None + """ + Present if the client supports non-'none' values for includeContext parameter. + SOFT-DEPRECATED: New implementations should use tools parameter instead. + """ + tools: SamplingToolsCapability | None = None + """ + Present if the client supports tools and toolChoice parameters in sampling requests. + Presence indicates full tool calling support during sampling. + """ + model_config = ConfigDict(extra="allow") + + +class TasksListCapability(BaseModel): + """Capability for tasks listing operations.""" + + model_config = ConfigDict(extra="allow") + + +class TasksCancelCapability(BaseModel): + """Capability for tasks cancel operations.""" + + model_config = ConfigDict(extra="allow") + + +class TasksCreateMessageCapability(BaseModel): + """Capability for tasks create messages.""" + + model_config = ConfigDict(extra="allow") + + +class TasksSamplingCapability(BaseModel): + """Capability for tasks sampling operations.""" + + model_config = ConfigDict(extra="allow") + + createMessage: TasksCreateMessageCapability | None = None + + +class TasksCreateElicitationCapability(BaseModel): + """Capability for tasks create elicitation operations.""" + + model_config = ConfigDict(extra="allow") + + +class TasksElicitationCapability(BaseModel): + """Capability for tasks elicitation operations.""" + + model_config = ConfigDict(extra="allow") + + create: TasksCreateElicitationCapability | None = None + + +class ClientTasksRequestsCapability(BaseModel): + """Capability for tasks requests operations.""" + + model_config = ConfigDict(extra="allow") + + sampling: TasksSamplingCapability | None = None + + elicitation: TasksElicitationCapability | None = None + + +class ClientTasksCapability(BaseModel): + """Capability for client tasks operations.""" model_config = ConfigDict(extra="allow") + list: TasksListCapability | None = None + """Whether this client supports tasks/list.""" + + cancel: TasksCancelCapability | None = None + """Whether this client supports tasks/cancel.""" + + requests: ClientTasksRequestsCapability | None = None + """Specifies which request types can be augmented with tasks.""" + class ClientCapabilities(BaseModel): """Capabilities a client may support.""" @@ -216,9 +420,17 @@ class ClientCapabilities(BaseModel): experimental: dict[str, dict[str, Any]] | None = None """Experimental, non-standard capabilities that the client supports.""" sampling: SamplingCapability | None = None - """Present if the client supports sampling from an LLM.""" + """ + Present if the client supports sampling from an LLM. + Can contain fine-grained capabilities like context and tools support. + """ + elicitation: ElicitationCapability | None = None + """Present if the client supports elicitation from the user.""" roots: RootsCapability | None = None """Present if the client supports listing roots.""" + tasks: ClientTasksCapability | None = None + """Present if the client supports task-augmented requests.""" + model_config = ConfigDict(extra="allow") @@ -254,6 +466,43 @@ class LoggingCapability(BaseModel): model_config = ConfigDict(extra="allow") +class CompletionsCapability(BaseModel): + """Capability for completions operations.""" + + model_config = ConfigDict(extra="allow") + + +class TasksCallCapability(BaseModel): + """Capability for tasks call operations.""" + + model_config = ConfigDict(extra="allow") + + +class TasksToolsCapability(BaseModel): + """Capability for tasks tools operations.""" + + model_config = ConfigDict(extra="allow") + call: TasksCallCapability | None = None + + +class ServerTasksRequestsCapability(BaseModel): + """Capability for tasks requests operations.""" + + model_config = ConfigDict(extra="allow") + + tools: TasksToolsCapability | None = None + + +class ServerTasksCapability(BaseModel): + """Capability for server tasks operations.""" + + model_config = ConfigDict(extra="allow") + + list: TasksListCapability | None = None + cancel: TasksCancelCapability | None = None + requests: ServerTasksRequestsCapability | None = None + + class ServerCapabilities(BaseModel): """Capabilities that a server may support.""" @@ -267,8 +516,157 @@ class ServerCapabilities(BaseModel): """Present if the server offers any resources to read.""" tools: ToolsCapability | None = None """Present if the server offers any tools to call.""" + completions: CompletionsCapability | None = None + """Present if the server offers autocompletion suggestions for prompts and resources.""" + tasks: ServerTasksCapability | None = None + """Present if the server supports task-augmented requests.""" + model_config = ConfigDict(extra="allow") + + +TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"] + +# Task status constants +TASK_STATUS_WORKING: Final[Literal["working"]] = "working" +TASK_STATUS_INPUT_REQUIRED: Final[Literal["input_required"]] = "input_required" +TASK_STATUS_COMPLETED: Final[Literal["completed"]] = "completed" +TASK_STATUS_FAILED: Final[Literal["failed"]] = "failed" +TASK_STATUS_CANCELLED: Final[Literal["cancelled"]] = "cancelled" + + +class RelatedTaskMetadata(BaseModel): + """ + Metadata for associating messages with a task. + + Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`. + """ + + model_config = ConfigDict(extra="allow") + taskId: str + """The task identifier this message is associated with.""" + + +class Task(BaseModel): + """Data associated with a task.""" + + model_config = ConfigDict(extra="allow") + + taskId: str + """The task identifier.""" + + status: TaskStatus + """Current task state.""" + + statusMessage: str | None = None + """ + Optional human-readable message describing the current task state. + This can provide context for any status, including: + - Reasons for "cancelled" status + - Summaries for "completed" status + - Diagnostic information for "failed" status (e.g., error details, what went wrong) + """ + + createdAt: datetime # Pydantic will enforce ISO 8601 and re-serialize as a string later + """ISO 8601 timestamp when the task was created.""" + + lastUpdatedAt: datetime + """ISO 8601 timestamp when the task was last updated.""" + + ttl: Annotated[int, Field(strict=True)] | None + """Actual retention duration from creation in milliseconds, null for unlimited.""" + + pollInterval: Annotated[int, Field(strict=True)] | None = None + """Suggested polling interval in milliseconds.""" + + +class CreateTaskResult(Result): + """A response to a task-augmented request.""" + + task: Task + + +class GetTaskRequestParams(RequestParams): + model_config = ConfigDict(extra="allow") + taskId: str + """The task identifier to query.""" + + +class GetTaskRequest(Request[GetTaskRequestParams, Literal["tasks/get"]]): + """A request to retrieve the state of a task.""" + + method: Literal["tasks/get"] = "tasks/get" + + params: GetTaskRequestParams + + +class GetTaskResult(Result, Task): + """The response to a tasks/get request.""" + + +class GetTaskPayloadRequestParams(RequestParams): model_config = ConfigDict(extra="allow") + taskId: str + """The task identifier to retrieve results for.""" + + +class GetTaskPayloadRequest(Request[GetTaskPayloadRequestParams, Literal["tasks/result"]]): + """A request to retrieve the result of a completed task.""" + + method: Literal["tasks/result"] = "tasks/result" + params: GetTaskPayloadRequestParams + + +class GetTaskPayloadResult(Result): + """ + The response to a tasks/result request. + The structure matches the result type of the original request. + For example, a tools/call task would return the CallToolResult structure. + """ + + +class CancelTaskRequestParams(RequestParams): + model_config = ConfigDict(extra="allow") + + taskId: str + """The task identifier to cancel.""" + + +class CancelTaskRequest(Request[CancelTaskRequestParams, Literal["tasks/cancel"]]): + """A request to cancel a task.""" + + method: Literal["tasks/cancel"] = "tasks/cancel" + params: CancelTaskRequestParams + + +class CancelTaskResult(Result, Task): + """The response to a tasks/cancel request.""" + + +class ListTasksRequest(PaginatedRequest[Literal["tasks/list"]]): + """A request to retrieve a list of tasks.""" + + method: Literal["tasks/list"] = "tasks/list" + + +class ListTasksResult(PaginatedResult): + """The response to a tasks/list request.""" + + tasks: list[Task] + + +class TaskStatusNotificationParams(NotificationParams, Task): + """Parameters for a `notifications/tasks/status` notification.""" + + +class TaskStatusNotification(Notification[TaskStatusNotificationParams, Literal["notifications/tasks/status"]]): + """ + An optional notification from the receiver to the requestor, informing them that a task's status has changed. + Receivers are not required to send these notifications + """ + + method: Literal["notifications/tasks/status"] = "notifications/tasks/status" + params: TaskStatusNotificationParams + class InitializeRequestParams(RequestParams): """Parameters for the initialize request.""" @@ -286,7 +684,7 @@ class InitializeRequest(Request[InitializeRequestParams, Literal["initialize"]]) to begin initialization. """ - method: Literal["initialize"] + method: Literal["initialize"] = "initialize" params: InitializeRequestParams @@ -301,15 +699,13 @@ class InitializeResult(Result): """Instructions describing how to use the server and its features.""" -class InitializedNotification( - Notification[NotificationParams | None, Literal["notifications/initialized"]] -): +class InitializedNotification(Notification[NotificationParams | None, Literal["notifications/initialized"]]): """ This notification is sent from the client to the server after initialization has finished. """ - method: Literal["notifications/initialized"] + method: Literal["notifications/initialized"] = "notifications/initialized" params: NotificationParams | None = None @@ -319,7 +715,7 @@ class PingRequest(Request[RequestParams | None, Literal["ping"]]): still alive. """ - method: Literal["ping"] + method: Literal["ping"] = "ping" params: RequestParams | None = None @@ -338,28 +734,28 @@ class ProgressNotificationParams(NotificationParams): """ total: float | None = None """Total number of items to process (or total progress required), if known.""" + message: str | None = None + """ + Message related to progress. This should provide relevant human readable + progress information. + """ model_config = ConfigDict(extra="allow") -class ProgressNotification( - Notification[ProgressNotificationParams, Literal["notifications/progress"]] -): +class ProgressNotification(Notification[ProgressNotificationParams, Literal["notifications/progress"]]): """ An out-of-band notification used to inform the receiver of a progress update for a long-running request. """ - method: Literal["notifications/progress"] + method: Literal["notifications/progress"] = "notifications/progress" params: ProgressNotificationParams -class ListResourcesRequest( - PaginatedRequest[RequestParams | None, Literal["resources/list"]] -): +class ListResourcesRequest(PaginatedRequest[Literal["resources/list"]]): """Sent from the client to request a list of resources the server has.""" - method: Literal["resources/list"] - params: RequestParams | None = None + method: Literal["resources/list"] = "resources/list" class Annotations(BaseModel): @@ -368,13 +764,11 @@ class Annotations(BaseModel): model_config = ConfigDict(extra="allow") -class Resource(BaseModel): +class Resource(BaseMetadata): """A known resource that the server is capable of reading.""" uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] """The URI of this resource.""" - name: str - """A human-readable name for this resource.""" description: str | None = None """A description of what this resource represents.""" mimeType: str | None = None @@ -386,11 +780,18 @@ class Resource(BaseModel): This can be used by Hosts to display file sizes and estimate context window usage. """ + icons: list[Icon] | None = None + """An optional list of icons for this resource.""" annotations: Annotations | None = None + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ model_config = ConfigDict(extra="allow") -class ResourceTemplate(BaseModel): +class ResourceTemplate(BaseMetadata): """A template description for resources available on the server.""" uriTemplate: str @@ -398,8 +799,6 @@ class ResourceTemplate(BaseModel): A URI template (according to RFC 6570) that can be used to construct resource URIs. """ - name: str - """A human-readable name for the type of resource this template refers to.""" description: str | None = None """A human-readable description of what this template is for.""" mimeType: str | None = None @@ -407,7 +806,14 @@ class ResourceTemplate(BaseModel): The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. """ + icons: list[Icon] | None = None + """An optional list of icons for this resource template.""" annotations: Annotations | None = None + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ model_config = ConfigDict(extra="allow") @@ -417,13 +823,10 @@ class ListResourcesResult(PaginatedResult): resources: list[Resource] -class ListResourceTemplatesRequest( - PaginatedRequest[RequestParams | None, Literal["resources/templates/list"]] -): +class ListResourceTemplatesRequest(PaginatedRequest[Literal["resources/templates/list"]]): """Sent from the client to request a list of resource templates the server has.""" - method: Literal["resources/templates/list"] - params: RequestParams | None = None + method: Literal["resources/templates/list"] = "resources/templates/list" class ListResourceTemplatesResult(PaginatedResult): @@ -443,12 +846,10 @@ class ReadResourceRequestParams(RequestParams): model_config = ConfigDict(extra="allow") -class ReadResourceRequest( - Request[ReadResourceRequestParams, Literal["resources/read"]] -): +class ReadResourceRequest(Request[ReadResourceRequestParams, Literal["resources/read"]]): """Sent from the client to the server, to read a specific resource URI.""" - method: Literal["resources/read"] + method: Literal["resources/read"] = "resources/read" params: ReadResourceRequestParams @@ -459,6 +860,11 @@ class ResourceContents(BaseModel): """The URI of this resource.""" mimeType: str | None = None """The MIME type of this resource, if known.""" + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ model_config = ConfigDict(extra="allow") @@ -486,16 +892,14 @@ class ReadResourceResult(Result): class ResourceListChangedNotification( - Notification[ - NotificationParams | None, Literal["notifications/resources/list_changed"] - ] + Notification[NotificationParams | None, Literal["notifications/resources/list_changed"]] ): """ An optional notification from the server to the client, informing it that the list of resources it can read from has changed. """ - method: Literal["notifications/resources/list_changed"] + method: Literal["notifications/resources/list_changed"] = "notifications/resources/list_changed" params: NotificationParams | None = None @@ -516,7 +920,7 @@ class SubscribeRequest(Request[SubscribeRequestParams, Literal["resources/subscr whenever a particular resource changes. """ - method: Literal["resources/subscribe"] + method: Literal["resources/subscribe"] = "resources/subscribe" params: SubscribeRequestParams @@ -528,15 +932,13 @@ class UnsubscribeRequestParams(RequestParams): model_config = ConfigDict(extra="allow") -class UnsubscribeRequest( - Request[UnsubscribeRequestParams, Literal["resources/unsubscribe"]] -): +class UnsubscribeRequest(Request[UnsubscribeRequestParams, Literal["resources/unsubscribe"]]): """ Sent from the client to request cancellation of resources/updated notifications from the server. """ - method: Literal["resources/unsubscribe"] + method: Literal["resources/unsubscribe"] = "resources/unsubscribe" params: UnsubscribeRequestParams @@ -552,26 +954,21 @@ class ResourceUpdatedNotificationParams(NotificationParams): class ResourceUpdatedNotification( - Notification[ - ResourceUpdatedNotificationParams, Literal["notifications/resources/updated"] - ] + Notification[ResourceUpdatedNotificationParams, Literal["notifications/resources/updated"]] ): """ A notification from the server to the client, informing it that a resource has changed and may need to be read again. """ - method: Literal["notifications/resources/updated"] + method: Literal["notifications/resources/updated"] = "notifications/resources/updated" params: ResourceUpdatedNotificationParams -class ListPromptsRequest( - PaginatedRequest[RequestParams | None, Literal["prompts/list"]] -): +class ListPromptsRequest(PaginatedRequest[Literal["prompts/list"]]): """Sent from the client to request a list of prompts and prompt templates.""" - method: Literal["prompts/list"] - params: RequestParams | None = None + method: Literal["prompts/list"] = "prompts/list" class PromptArgument(BaseModel): @@ -586,15 +983,20 @@ class PromptArgument(BaseModel): model_config = ConfigDict(extra="allow") -class Prompt(BaseModel): +class Prompt(BaseMetadata): """A prompt or prompt template that the server offers.""" - name: str - """The name of the prompt or prompt template.""" description: str | None = None """An optional description of what this prompt provides.""" arguments: list[PromptArgument] | None = None """A list of arguments to use for templating the prompt.""" + icons: list[Icon] | None = None + """An optional list of icons for this prompt.""" + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ model_config = ConfigDict(extra="allow") @@ -617,7 +1019,7 @@ class GetPromptRequestParams(RequestParams): class GetPromptRequest(Request[GetPromptRequestParams, Literal["prompts/get"]]): """Used by the client to get a prompt provided by the server.""" - method: Literal["prompts/get"] + method: Literal["prompts/get"] = "prompts/get" params: GetPromptRequestParams @@ -628,6 +1030,11 @@ class TextContent(BaseModel): text: str """The text content of the message.""" annotations: Annotations | None = None + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ model_config = ConfigDict(extra="allow") @@ -643,16 +1050,129 @@ class ImageContent(BaseModel): image types. """ annotations: Annotations | None = None + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ model_config = ConfigDict(extra="allow") +class AudioContent(BaseModel): + """Audio content for a message.""" + + type: Literal["audio"] + data: str + """The base64-encoded audio data.""" + mimeType: str + """ + The MIME type of the audio. Different providers may support different + audio types. + """ + annotations: Annotations | None = None + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ + model_config = ConfigDict(extra="allow") + + +class ToolUseContent(BaseModel): + """ + Content representing an assistant's request to invoke a tool. + + This content type appears in assistant messages when the LLM wants to call a tool + during sampling. The server should execute the tool and return a ToolResultContent + in the next user message. + """ + + type: Literal["tool_use"] + """Discriminator for tool use content.""" + + name: str + """The name of the tool to invoke. Must match a tool name from the request's tools array.""" + + id: str + """Unique identifier for this tool call, used to correlate with ToolResultContent.""" + + input: dict[str, Any] + """Arguments to pass to the tool. Must conform to the tool's inputSchema.""" + + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ + model_config = ConfigDict(extra="allow") + + +class ToolResultContent(BaseModel): + """ + Content representing the result of a tool execution. + + This content type appears in user messages as a response to a ToolUseContent + from the assistant. It contains the output of executing the requested tool. + """ + + type: Literal["tool_result"] + """Discriminator for tool result content.""" + + toolUseId: str + """The unique identifier that corresponds to the tool call's id field.""" + + content: list["ContentBlock"] = [] + """ + A list of content objects representing the tool result. + Defaults to empty list if not provided. + """ + + structuredContent: dict[str, Any] | None = None + """ + Optional structured tool output that matches the tool's outputSchema (if defined). + """ + + isError: bool | None = None + """Whether the tool execution resulted in an error.""" + + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ + model_config = ConfigDict(extra="allow") + + +SamplingMessageContentBlock: TypeAlias = TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent +"""Content block types allowed in sampling messages.""" + +SamplingContent: TypeAlias = TextContent | ImageContent | AudioContent +"""Basic content types for sampling responses (without tool use). +Used for backwards-compatible CreateMessageResult when tools are not used.""" + + class SamplingMessage(BaseModel): """Describes a message issued to or received from an LLM API.""" role: Role - content: TextContent | ImageContent + content: SamplingMessageContentBlock | list[SamplingMessageContentBlock] + """ + Message content. Can be a single content block or an array of content blocks + for multi-modal messages and tool interactions. + """ + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ model_config = ConfigDict(extra="allow") + @property + def content_as_list(self) -> list[SamplingMessageContentBlock]: + """Returns the content as a list of content blocks, regardless of whether + it was originally a single block or a list.""" + return self.content if isinstance(self.content, list) else [self.content] + class EmbeddedResource(BaseModel): """ @@ -665,14 +1185,36 @@ class EmbeddedResource(BaseModel): type: Literal["resource"] resource: TextResourceContents | BlobResourceContents annotations: Annotations | None = None + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ model_config = ConfigDict(extra="allow") +class ResourceLink(Resource): + """ + A resource that the server is capable of reading, included in a prompt or tool call result. + + Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. + """ + + type: Literal["resource_link"] + + +ContentBlock = TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource +"""A content block that can be used in prompts and tool results.""" + +Content: TypeAlias = ContentBlock +# """DEPRECATED: Content is deprecated, you should use ContentBlock directly.""" + + class PromptMessage(BaseModel): """Describes a message returned as part of a prompt.""" role: Role - content: TextContent | ImageContent | EmbeddedResource + content: ContentBlock model_config = ConfigDict(extra="allow") @@ -685,35 +1227,115 @@ class GetPromptResult(Result): class PromptListChangedNotification( - Notification[ - NotificationParams | None, Literal["notifications/prompts/list_changed"] - ] + Notification[NotificationParams | None, Literal["notifications/prompts/list_changed"]] ): """ An optional notification from the server to the client, informing it that the list of prompts it offers has changed. """ - method: Literal["notifications/prompts/list_changed"] + method: Literal["notifications/prompts/list_changed"] = "notifications/prompts/list_changed" params: NotificationParams | None = None -class ListToolsRequest(PaginatedRequest[RequestParams | None, Literal["tools/list"]]): +class ListToolsRequest(PaginatedRequest[Literal["tools/list"]]): """Sent from the client to request a list of tools the server has.""" - method: Literal["tools/list"] - params: RequestParams | None = None + method: Literal["tools/list"] = "tools/list" + + +class ToolAnnotations(BaseModel): + """ + Additional properties describing a Tool to clients. + NOTE: all properties in ToolAnnotations are **hints**. + They are not guaranteed to provide a faithful description of + tool behavior (including descriptive properties like `title`). -class Tool(BaseModel): + Clients should never make tool use decisions based on ToolAnnotations + received from untrusted servers. + """ + + title: str | None = None + """A human-readable title for the tool.""" + + readOnlyHint: bool | None = None + """ + If true, the tool does not modify its environment. + Default: false + """ + + destructiveHint: bool | None = None + """ + If true, the tool may perform destructive updates to its environment. + If false, the tool performs only additive updates. + (This property is meaningful only when `readOnlyHint == false`) + Default: true + """ + + idempotentHint: bool | None = None + """ + If true, calling the tool repeatedly with the same arguments + will have no additional effect on the its environment. + (This property is meaningful only when `readOnlyHint == false`) + Default: false + """ + + openWorldHint: bool | None = None + """ + If true, this tool may interact with an "open world" of external + entities. If false, the tool's domain of interaction is closed. + For example, the world of a web search tool is open, whereas that + of a memory tool is not. + Default: true + """ + + model_config = ConfigDict(extra="allow") + + +class ToolExecution(BaseModel): + """Execution-related properties for a tool.""" + + model_config = ConfigDict(extra="allow") + + taskSupport: TaskExecutionMode | None = None + """ + Indicates whether this tool supports task-augmented execution. + This allows clients to handle long-running operations through polling + the task system. + + - "forbidden": Tool does not support task-augmented execution (default when absent) + - "optional": Tool may support task-augmented execution + - "required": Tool requires task-augmented execution + + Default: "forbidden" + """ + + +class Tool(BaseMetadata): """Definition for a tool the client can call.""" - name: str - """The name of the tool.""" description: str | None = None """A human-readable description of the tool.""" inputSchema: dict[str, Any] """A JSON Schema object defining the expected parameters for the tool.""" + outputSchema: dict[str, Any] | None = None + """ + An optional JSON Schema object defining the structure of the tool's output + returned in the structuredContent field of a CallToolResult. + """ + icons: list[Icon] | None = None + """An optional list of icons for this tool.""" + annotations: ToolAnnotations | None = None + """Optional additional tool information.""" + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ + + execution: ToolExecution | None = None + model_config = ConfigDict(extra="allow") @@ -734,32 +1356,30 @@ class CallToolRequestParams(RequestParams): class CallToolRequest(Request[CallToolRequestParams, Literal["tools/call"]]): """Used by the client to invoke a tool provided by the server.""" - method: Literal["tools/call"] + method: Literal["tools/call"] = "tools/call" params: CallToolRequestParams class CallToolResult(Result): """The server's response to a tool call.""" - content: list[TextContent | ImageContent | EmbeddedResource] + content: list[ContentBlock] + structuredContent: dict[str, Any] | None = None + """An optional JSON object that represents the structured result of the tool call.""" isError: bool = False -class ToolListChangedNotification( - Notification[NotificationParams | None, Literal["notifications/tools/list_changed"]] -): +class ToolListChangedNotification(Notification[NotificationParams | None, Literal["notifications/tools/list_changed"]]): """ An optional notification from the server to the client, informing it that the list of tools it offers has changed. """ - method: Literal["notifications/tools/list_changed"] + method: Literal["notifications/tools/list_changed"] = "notifications/tools/list_changed" params: NotificationParams | None = None -LoggingLevel = Literal[ - "debug", "info", "notice", "warning", "error", "critical", "alert", "emergency" -] +LoggingLevel = Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"] class SetLevelRequestParams(RequestParams): @@ -773,7 +1393,7 @@ class SetLevelRequestParams(RequestParams): class SetLevelRequest(Request[SetLevelRequestParams, Literal["logging/setLevel"]]): """A request from the client to the server, to enable or adjust logging.""" - method: Literal["logging/setLevel"] + method: Literal["logging/setLevel"] = "logging/setLevel" params: SetLevelRequestParams @@ -792,12 +1412,10 @@ class LoggingMessageNotificationParams(NotificationParams): model_config = ConfigDict(extra="allow") -class LoggingMessageNotification( - Notification[LoggingMessageNotificationParams, Literal["notifications/message"]] -): +class LoggingMessageNotification(Notification[LoggingMessageNotificationParams, Literal["notifications/message"]]): """Notification of a log message passed from server to client.""" - method: Literal["notifications/message"] + method: Literal["notifications/message"] = "notifications/message" params: LoggingMessageNotificationParams @@ -864,6 +1482,25 @@ class ModelPreferences(BaseModel): model_config = ConfigDict(extra="allow") +class ToolChoice(BaseModel): + """ + Controls tool usage behavior during sampling. + + Allows the server to specify whether and how the LLM should use tools + in its response. + """ + + mode: Literal["auto", "required", "none"] | None = None + """ + Controls when tools are used: + - "auto": Model decides whether to use tools (default) + - "required": Model MUST use at least one tool before completing + - "none": Model should not use tools + """ + + model_config = ConfigDict(extra="allow") + + class CreateMessageRequestParams(RequestParams): """Parameters for creating a message.""" @@ -886,33 +1523,75 @@ class CreateMessageRequestParams(RequestParams): stopSequences: list[str] | None = None metadata: dict[str, Any] | None = None """Optional metadata to pass through to the LLM provider.""" + tools: list["Tool"] | None = None + """ + Tool definitions for the LLM to use during sampling. + Requires clientCapabilities.sampling.tools to be present. + """ + toolChoice: ToolChoice | None = None + """ + Controls tool usage behavior. + Requires clientCapabilities.sampling.tools and the tools parameter to be present. + """ model_config = ConfigDict(extra="allow") -class CreateMessageRequest( - Request[CreateMessageRequestParams, Literal["sampling/createMessage"]] -): +class CreateMessageRequest(Request[CreateMessageRequestParams, Literal["sampling/createMessage"]]): """A request from the server to sample an LLM via the client.""" - method: Literal["sampling/createMessage"] + method: Literal["sampling/createMessage"] = "sampling/createMessage" params: CreateMessageRequestParams -StopReason = Literal["endTurn", "stopSequence", "maxTokens"] | str +StopReason = Literal["endTurn", "stopSequence", "maxTokens", "toolUse"] | str class CreateMessageResult(Result): - """The client's response to a sampling/create_message request from the server.""" + """The client's response to a sampling/create_message request from the server. + + This is the backwards-compatible version that returns single content (no arrays). + Used when the request does not include tools. + """ role: Role - content: TextContent | ImageContent + """The role of the message sender (typically 'assistant' for LLM responses).""" + content: SamplingContent + """Response content. Single content block (text, image, or audio).""" model: str """The name of the model that generated the message.""" stopReason: StopReason | None = None """The reason why sampling stopped, if known.""" -class ResourceReference(BaseModel): +class CreateMessageResultWithTools(Result): + """The client's response to a sampling/create_message request when tools were provided. + + This version supports array content for tool use flows. + """ + + role: Role + """The role of the message sender (typically 'assistant' for LLM responses).""" + content: SamplingMessageContentBlock | list[SamplingMessageContentBlock] + """ + Response content. May be a single content block or an array. + May include ToolUseContent if stopReason is 'toolUse'. + """ + model: str + """The name of the model that generated the message.""" + stopReason: StopReason | None = None + """ + The reason why sampling stopped, if known. + 'toolUse' indicates the model wants to use a tool. + """ + + @property + def content_as_list(self) -> list[SamplingMessageContentBlock]: + """Returns the content as a list of content blocks, regardless of whether + it was originally a single block or a list.""" + return self.content if isinstance(self.content, list) else [self.content] + + +class ResourceTemplateReference(BaseModel): """A reference to a resource or resource template definition.""" type: Literal["ref/resource"] @@ -921,6 +1600,11 @@ class ResourceReference(BaseModel): model_config = ConfigDict(extra="allow") +@deprecated("`ResourceReference` is deprecated, you should use `ResourceTemplateReference`.") +class ResourceReference(ResourceTemplateReference): + pass + + class PromptReference(BaseModel): """Identifies a prompt.""" @@ -940,18 +1624,28 @@ class CompletionArgument(BaseModel): model_config = ConfigDict(extra="allow") +class CompletionContext(BaseModel): + """Additional, optional context for completions.""" + + arguments: dict[str, str] | None = None + """Previously-resolved variables in a URI template or prompt.""" + model_config = ConfigDict(extra="allow") + + class CompleteRequestParams(RequestParams): """Parameters for completion requests.""" - ref: ResourceReference | PromptReference + ref: ResourceTemplateReference | PromptReference argument: CompletionArgument + context: CompletionContext | None = None + """Additional, optional context for completions""" model_config = ConfigDict(extra="allow") class CompleteRequest(Request[CompleteRequestParams, Literal["completion/complete"]]): """A request from the client to the server, to ask for completion options.""" - method: Literal["completion/complete"] + method: Literal["completion/complete"] = "completion/complete" params: CompleteRequestParams @@ -990,7 +1684,7 @@ class ListRootsRequest(Request[RequestParams | None, Literal["roots/list"]]): structure or access specific locations that the client has permission to read from. """ - method: Literal["roots/list"] + method: Literal["roots/list"] = "roots/list" params: RequestParams | None = None @@ -1009,6 +1703,11 @@ class Root(BaseModel): identifier for the root, which may be useful for display purposes or for referencing the root in other parts of the application. """ + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + """ + See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + for notes on _meta usage. + """ model_config = ConfigDict(extra="allow") @@ -1034,97 +1733,267 @@ class RootsListChangedNotification( using the ListRootsRequest. """ - method: Literal["notifications/roots/list_changed"] + method: Literal["notifications/roots/list_changed"] = "notifications/roots/list_changed" params: NotificationParams | None = None class CancelledNotificationParams(NotificationParams): """Parameters for cancellation notifications.""" - requestId: RequestId - """The ID of the request to cancel.""" + requestId: RequestId | None = None + """ + The ID of the request to cancel. + + This MUST correspond to the ID of a request previously issued in the same direction. + This MUST be provided for cancelling non-task requests. + This MUST NOT be used for cancelling tasks (use the `tasks/cancel` request instead). + """ reason: str | None = None """An optional string describing the reason for the cancellation.""" + model_config = ConfigDict(extra="allow") -class CancelledNotification( - Notification[CancelledNotificationParams, Literal["notifications/cancelled"]] -): +class CancelledNotification(Notification[CancelledNotificationParams, Literal["notifications/cancelled"]]): """ This notification can be sent by either side to indicate that it is canceling a previously-issued request. """ - method: Literal["notifications/cancelled"] + method: Literal["notifications/cancelled"] = "notifications/cancelled" params: CancelledNotificationParams -class ClientRequest( - RootModel[ - PingRequest - | InitializeRequest - | CompleteRequest - | SetLevelRequest - | GetPromptRequest - | ListPromptsRequest - | ListResourcesRequest - | ListResourceTemplatesRequest - | ReadResourceRequest - | SubscribeRequest - | UnsubscribeRequest - | CallToolRequest - | ListToolsRequest - ] +class ElicitCompleteNotificationParams(NotificationParams): + """Parameters for elicitation completion notifications.""" + + elicitationId: str + """The unique identifier of the elicitation that was completed.""" + + model_config = ConfigDict(extra="allow") + + +class ElicitCompleteNotification( + Notification[ElicitCompleteNotificationParams, Literal["notifications/elicitation/complete"]] ): + """ + A notification from the server to the client, informing it that a URL mode + elicitation has been completed. + + Clients MAY use the notification to automatically retry requests that received a + URLElicitationRequiredError, update the user interface, or otherwise continue + an interaction. However, because delivery of the notification is not guaranteed, + clients must not wait indefinitely for a notification from the server. + """ + + method: Literal["notifications/elicitation/complete"] = "notifications/elicitation/complete" + params: ElicitCompleteNotificationParams + + +ClientRequestType: TypeAlias = ( + PingRequest + | InitializeRequest + | CompleteRequest + | SetLevelRequest + | GetPromptRequest + | ListPromptsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscribeRequest + | UnsubscribeRequest + | CallToolRequest + | ListToolsRequest + | GetTaskRequest + | GetTaskPayloadRequest + | ListTasksRequest + | CancelTaskRequest +) + + +class ClientRequest(RootModel[ClientRequestType]): pass -class ClientNotification( - RootModel[ - CancelledNotification - | ProgressNotification - | InitializedNotification - | RootsListChangedNotification - ] -): +ClientNotificationType: TypeAlias = ( + CancelledNotification + | ProgressNotification + | InitializedNotification + | RootsListChangedNotification + | TaskStatusNotification +) + + +class ClientNotification(RootModel[ClientNotificationType]): pass -class ClientResult(RootModel[EmptyResult | CreateMessageResult | ListRootsResult]): +# Type for elicitation schema - a JSON Schema dict +ElicitRequestedSchema: TypeAlias = dict[str, Any] +"""Schema for elicitation requests.""" + + +class ElicitRequestFormParams(RequestParams): + """Parameters for form mode elicitation requests. + + Form mode collects non-sensitive information from the user via an in-band form + rendered by the client. + """ + + mode: Literal["form"] = "form" + """The elicitation mode (always "form" for this type).""" + + message: str + """The message to present to the user describing what information is being requested.""" + + requestedSchema: ElicitRequestedSchema + """ + A restricted subset of JSON Schema defining the structure of expected response. + Only top-level properties are allowed, without nesting. + """ + + model_config = ConfigDict(extra="allow") + + +class ElicitRequestURLParams(RequestParams): + """Parameters for URL mode elicitation requests. + + URL mode directs users to external URLs for sensitive out-of-band interactions + like OAuth flows, credential collection, or payment processing. + """ + + mode: Literal["url"] = "url" + """The elicitation mode (always "url" for this type).""" + + message: str + """The message to present to the user explaining why the interaction is needed.""" + + url: str + """The URL that the user should navigate to.""" + + elicitationId: str + """ + The ID of the elicitation, which must be unique within the context of the server. + The client MUST treat this ID as an opaque value. + """ + + model_config = ConfigDict(extra="allow") + + +# Union type for elicitation request parameters +ElicitRequestParams: TypeAlias = ElicitRequestURLParams | ElicitRequestFormParams +"""Parameters for elicitation requests - either form or URL mode.""" + + +class ElicitRequest(Request[ElicitRequestParams, Literal["elicitation/create"]]): + """A request from the server to elicit information from the client.""" + + method: Literal["elicitation/create"] = "elicitation/create" + params: ElicitRequestParams + + +class ElicitResult(Result): + """The client's response to an elicitation request.""" + + action: Literal["accept", "decline", "cancel"] + """ + The user action in response to the elicitation. + - "accept": User submitted the form/confirmed the action (or consented to URL navigation) + - "decline": User explicitly declined the action + - "cancel": User dismissed without making an explicit choice + """ + + content: dict[str, str | int | float | bool | list[str] | None] | None = None + """ + The submitted form data, only present when action is "accept" in form mode. + Contains values matching the requested schema. Values can be strings, integers, + booleans, or arrays of strings. + For URL mode, this field is omitted. + """ + + +class ElicitationRequiredErrorData(BaseModel): + """Error data for URLElicitationRequiredError. + + Servers return this when a request cannot be processed until one or more + URL mode elicitations are completed. + """ + + elicitations: list[ElicitRequestURLParams] + """List of URL mode elicitations that must be completed.""" + + model_config = ConfigDict(extra="allow") + + +ClientResultType: TypeAlias = ( + EmptyResult + | CreateMessageResult + | CreateMessageResultWithTools + | ListRootsResult + | ElicitResult + | GetTaskResult + | GetTaskPayloadResult + | ListTasksResult + | CancelTaskResult + | CreateTaskResult +) + + +class ClientResult(RootModel[ClientResultType]): pass -class ServerRequest(RootModel[PingRequest | CreateMessageRequest | ListRootsRequest]): +ServerRequestType: TypeAlias = ( + PingRequest + | CreateMessageRequest + | ListRootsRequest + | ElicitRequest + | GetTaskRequest + | GetTaskPayloadRequest + | ListTasksRequest + | CancelTaskRequest +) + + +class ServerRequest(RootModel[ServerRequestType]): pass -class ServerNotification( - RootModel[ - CancelledNotification - | ProgressNotification - | LoggingMessageNotification - | ResourceUpdatedNotification - | ResourceListChangedNotification - | ToolListChangedNotification - | PromptListChangedNotification - ] -): +ServerNotificationType: TypeAlias = ( + CancelledNotification + | ProgressNotification + | LoggingMessageNotification + | ResourceUpdatedNotification + | ResourceListChangedNotification + | ToolListChangedNotification + | PromptListChangedNotification + | ElicitCompleteNotification + | TaskStatusNotification +) + + +class ServerNotification(RootModel[ServerNotificationType]): pass -class ServerResult( - RootModel[ - EmptyResult - | InitializeResult - | CompleteResult - | GetPromptResult - | ListPromptsResult - | ListResourcesResult - | ListResourceTemplatesResult - | ReadResourceResult - | CallToolResult - | ListToolsResult - ] -): +ServerResultType: TypeAlias = ( + EmptyResult + | InitializeResult + | CompleteResult + | GetPromptResult + | ListPromptsResult + | ListResourcesResult + | ListResourceTemplatesResult + | ReadResourceResult + | CallToolResult + | ListToolsResult + | GetTaskResult + | GetTaskPayloadResult + | ListTasksResult + | CancelTaskResult + | CreateTaskResult +) + + +class ServerResult(RootModel[ServerResultType]): pass diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py new file mode 100644 index 0000000000..44f4ab4d31 --- /dev/null +++ b/tests/cli/test_utils.py @@ -0,0 +1,101 @@ +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + +from mcp.cli.cli import _build_uv_command, _get_npx_command, _parse_file_path # type: ignore[reportPrivateUsage] + + +@pytest.mark.parametrize( + "spec, expected_obj", + [ + ("server.py", None), + ("foo.py:srv_obj", "srv_obj"), + ], +) +def test_parse_file_path_accepts_valid_specs(tmp_path: Path, spec: str, expected_obj: str | None): + """Should accept valid file specs.""" + file = tmp_path / spec.split(":")[0] + file.write_text("x = 1") + path, obj = _parse_file_path(f"{file}:{expected_obj}" if ":" in spec else str(file)) + assert path == file.resolve() + assert obj == expected_obj + + +def test_parse_file_path_missing(tmp_path: Path): + """Should system exit if a file is missing.""" + with pytest.raises(SystemExit): + _parse_file_path(str(tmp_path / "missing.py")) + + +def test_parse_file_exit_on_dir(tmp_path: Path): + """Should system exit if a directory is passed""" + dir_path = tmp_path / "dir" + dir_path.mkdir() + with pytest.raises(SystemExit): + _parse_file_path(str(dir_path)) + + +def test_build_uv_command_minimal(): + """Should emit core command when no extras specified.""" + cmd = _build_uv_command("foo.py") + assert cmd == ["uv", "run", "--with", "mcp", "mcp", "run", "foo.py"] + + +def test_build_uv_command_adds_editable_and_packages(): + """Should include --with-editable and every --with pkg in correct order.""" + test_path = Path("/pkg") + cmd = _build_uv_command( + "foo.py", + with_editable=test_path, + with_packages=["package1", "package2"], + ) + assert cmd == [ + "uv", + "run", + "--with", + "mcp", + "--with-editable", + str(test_path), # Use str() to match what the function does + "--with", + "package1", + "--with", + "package2", + "mcp", + "run", + "foo.py", + ] + + +def test_get_npx_unix_like(monkeypatch: pytest.MonkeyPatch): + """Should return "npx" on unix-like systems.""" + monkeypatch.setattr(sys, "platform", "linux") + assert _get_npx_command() == "npx" + + +def test_get_npx_windows(monkeypatch: pytest.MonkeyPatch): + """Should return one of the npx candidates on Windows.""" + candidates = ["npx.cmd", "npx.exe", "npx"] + + def fake_run(cmd: list[str], **kw: Any) -> subprocess.CompletedProcess[bytes]: + if cmd[0] in candidates: + return subprocess.CompletedProcess(cmd, 0) + else: # pragma: no cover + raise subprocess.CalledProcessError(1, cmd[0]) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(subprocess, "run", fake_run) + assert _get_npx_command() in candidates + + +def test_get_npx_returns_none_when_npx_missing(monkeypatch: pytest.MonkeyPatch): + """Should give None if every candidate fails.""" + monkeypatch.setattr(sys, "platform", "win32", raising=False) + + def always_fail(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess[bytes]: + raise subprocess.CalledProcessError(1, args[0]) + + monkeypatch.setattr(subprocess, "run", always_fail) + assert _get_npx_command() is None diff --git a/tests/client/auth/extensions/test_client_credentials.py b/tests/client/auth/extensions/test_client_credentials.py new file mode 100644 index 0000000000..6d134af742 --- /dev/null +++ b/tests/client/auth/extensions/test_client_credentials.py @@ -0,0 +1,431 @@ +import urllib.parse + +import jwt +import pytest +from pydantic import AnyHttpUrl, AnyUrl + +from mcp.client.auth.extensions.client_credentials import ( + ClientCredentialsOAuthProvider, + JWTParameters, + PrivateKeyJWTOAuthProvider, + RFC7523OAuthClientProvider, + SignedJWTParameters, + static_assertion_provider, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata, OAuthToken + + +class MockTokenStorage: + """Mock token storage for testing.""" + + def __init__(self): + self._tokens: OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: # pragma: no cover + return self._tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: # pragma: no cover + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: # pragma: no cover + return self._client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: # pragma: no cover + self._client_info = client_info + + +@pytest.fixture +def mock_storage(): + return MockTokenStorage() + + +@pytest.fixture +def client_metadata(): + return OAuthClientMetadata( + client_name="Test Client", + client_uri=AnyHttpUrl("https://example.com"), + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + scope="read write", + ) + + +@pytest.fixture +def rfc7523_oauth_provider(client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage): + async def redirect_handler(url: str) -> None: # pragma: no cover + """Mock redirect handler.""" + pass + + async def callback_handler() -> tuple[str, str | None]: # pragma: no cover + """Mock callback handler.""" + return "test_auth_code", "test_state" + + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return RFC7523OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + + +class TestOAuthFlowClientCredentials: + """Test OAuth flow behavior for client credentials flows.""" + + @pytest.mark.anyio + async def test_token_exchange_request_jwt_predefined(self, rfc7523_oauth_provider: RFC7523OAuthClientProvider): + """Test token exchange request building with a predefined JWT assertion.""" + # Set up required context + rfc7523_oauth_provider.context.client_info = OAuthClientInformationFull( + grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"], + token_endpoint_auth_method="private_key_jwt", + redirect_uris=None, + scope="read write", + ) + rfc7523_oauth_provider.context.oauth_metadata = OAuthMetadata( + issuer=AnyHttpUrl("https://api.example.com"), + authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://api.example.com/token"), + registration_endpoint=AnyHttpUrl("https://api.example.com/register"), + ) + rfc7523_oauth_provider.context.client_metadata = rfc7523_oauth_provider.context.client_info + rfc7523_oauth_provider.context.protocol_version = "2025-06-18" + rfc7523_oauth_provider.jwt_parameters = JWTParameters( + # https://www.jwt.io + assertion="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" + ) + + request = await rfc7523_oauth_provider._exchange_token_jwt_bearer() + + assert request.method == "POST" + assert str(request.url) == "https://api.example.com/token" + assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" + + # Check form data + content = urllib.parse.unquote_plus(request.content.decode()) + assert "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" in content + assert "scope=read write" in content + assert "resource=https://api.example.com/v1/mcp" in content + assert ( + "assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" + in content + ) + + @pytest.mark.anyio + async def test_token_exchange_request_jwt(self, rfc7523_oauth_provider: RFC7523OAuthClientProvider): + """Test token exchange request building wiith a generated JWT assertion.""" + # Set up required context + rfc7523_oauth_provider.context.client_info = OAuthClientInformationFull( + grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"], + token_endpoint_auth_method="private_key_jwt", + redirect_uris=None, + scope="read write", + ) + rfc7523_oauth_provider.context.oauth_metadata = OAuthMetadata( + issuer=AnyHttpUrl("https://api.example.com"), + authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://api.example.com/token"), + registration_endpoint=AnyHttpUrl("https://api.example.com/register"), + ) + rfc7523_oauth_provider.context.client_metadata = rfc7523_oauth_provider.context.client_info + rfc7523_oauth_provider.context.protocol_version = "2025-06-18" + rfc7523_oauth_provider.jwt_parameters = JWTParameters( + issuer="foo", + subject="1234567890", + claims={ + "name": "John Doe", + "admin": True, + "iat": 1516239022, + }, + jwt_signing_algorithm="HS256", + jwt_signing_key="a-string-secret-at-least-256-bits-long", + jwt_lifetime_seconds=300, + ) + + request = await rfc7523_oauth_provider._exchange_token_jwt_bearer() + + assert request.method == "POST" + assert str(request.url) == "https://api.example.com/token" + assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" + + # Check form data + content = urllib.parse.unquote_plus(request.content.decode()).split("&") + assert "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" in content + assert "scope=read write" in content + assert "resource=https://api.example.com/v1/mcp" in content + + # Check assertion + assertion = next(param for param in content if param.startswith("assertion="))[len("assertion=") :] + claims = jwt.decode( + assertion, + key="a-string-secret-at-least-256-bits-long", + algorithms=["HS256"], + audience="https://api.example.com/", + subject="1234567890", + issuer="foo", + verify=True, + ) + assert claims["name"] == "John Doe" + assert claims["admin"] + assert claims["iat"] == 1516239022 + + +class TestClientCredentialsOAuthProvider: + """Test ClientCredentialsOAuthProvider.""" + + @pytest.mark.anyio + async def test_init_sets_client_info(self, mock_storage: MockTokenStorage): + """Test that _initialize sets client_info.""" + provider = ClientCredentialsOAuthProvider( + server_url="https://api.example.com", + storage=mock_storage, + client_id="test-client-id", + client_secret="test-client-secret", + ) + + # client_info is set during _initialize + await provider._initialize() + + assert provider.context.client_info is not None + assert provider.context.client_info.client_id == "test-client-id" + assert provider.context.client_info.client_secret == "test-client-secret" + assert provider.context.client_info.grant_types == ["client_credentials"] + assert provider.context.client_info.token_endpoint_auth_method == "client_secret_basic" + + @pytest.mark.anyio + async def test_init_with_scopes(self, mock_storage: MockTokenStorage): + """Test that constructor accepts scopes.""" + provider = ClientCredentialsOAuthProvider( + server_url="https://api.example.com", + storage=mock_storage, + client_id="test-client-id", + client_secret="test-client-secret", + scopes="read write", + ) + + await provider._initialize() + assert provider.context.client_info is not None + assert provider.context.client_info.scope == "read write" + + @pytest.mark.anyio + async def test_init_with_client_secret_post(self, mock_storage: MockTokenStorage): + """Test that constructor accepts client_secret_post auth method.""" + provider = ClientCredentialsOAuthProvider( + server_url="https://api.example.com", + storage=mock_storage, + client_id="test-client-id", + client_secret="test-client-secret", + token_endpoint_auth_method="client_secret_post", + ) + + await provider._initialize() + assert provider.context.client_info is not None + assert provider.context.client_info.token_endpoint_auth_method == "client_secret_post" + + @pytest.mark.anyio + async def test_exchange_token_client_credentials(self, mock_storage: MockTokenStorage): + """Test token exchange request building.""" + provider = ClientCredentialsOAuthProvider( + server_url="https://api.example.com/v1/mcp", + storage=mock_storage, + client_id="test-client-id", + client_secret="test-client-secret", + scopes="read write", + ) + provider.context.oauth_metadata = OAuthMetadata( + issuer=AnyHttpUrl("https://api.example.com"), + authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://api.example.com/token"), + ) + provider.context.protocol_version = "2025-06-18" + + request = await provider._perform_authorization() + + assert request.method == "POST" + assert str(request.url) == "https://api.example.com/token" + + content = urllib.parse.unquote_plus(request.content.decode()) + assert "grant_type=client_credentials" in content + assert "scope=read write" in content + assert "resource=https://api.example.com/v1/mcp" in content + + @pytest.mark.anyio + async def test_exchange_token_without_scopes(self, mock_storage: MockTokenStorage): + """Test token exchange without scopes.""" + provider = ClientCredentialsOAuthProvider( + server_url="https://api.example.com/v1/mcp", + storage=mock_storage, + client_id="test-client-id", + client_secret="test-client-secret", + ) + provider.context.oauth_metadata = OAuthMetadata( + issuer=AnyHttpUrl("https://api.example.com"), + authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://api.example.com/token"), + ) + provider.context.protocol_version = "2024-11-05" # Old version - no resource param + + request = await provider._perform_authorization() + + content = urllib.parse.unquote_plus(request.content.decode()) + assert "grant_type=client_credentials" in content + assert "scope=" not in content + assert "resource=" not in content + + +class TestPrivateKeyJWTOAuthProvider: + """Test PrivateKeyJWTOAuthProvider.""" + + @pytest.mark.anyio + async def test_init_sets_client_info(self, mock_storage: MockTokenStorage): + """Test that _initialize sets client_info.""" + + async def mock_assertion_provider(audience: str) -> str: # pragma: no cover + return "mock-jwt" + + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=mock_storage, + client_id="test-client-id", + assertion_provider=mock_assertion_provider, + ) + + # client_info is set during _initialize + await provider._initialize() + + assert provider.context.client_info is not None + assert provider.context.client_info.client_id == "test-client-id" + assert provider.context.client_info.grant_types == ["client_credentials"] + assert provider.context.client_info.token_endpoint_auth_method == "private_key_jwt" + + @pytest.mark.anyio + async def test_exchange_token_client_credentials(self, mock_storage: MockTokenStorage): + """Test token exchange request building with assertion provider.""" + + async def mock_assertion_provider(audience: str) -> str: + return f"jwt-for-{audience}" + + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com/v1/mcp", + storage=mock_storage, + client_id="test-client-id", + assertion_provider=mock_assertion_provider, + scopes="read write", + ) + provider.context.oauth_metadata = OAuthMetadata( + issuer=AnyHttpUrl("https://auth.example.com"), + authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://auth.example.com/token"), + ) + provider.context.protocol_version = "2025-06-18" + + request = await provider._perform_authorization() + + assert request.method == "POST" + assert str(request.url) == "https://auth.example.com/token" + + content = urllib.parse.unquote_plus(request.content.decode()) + assert "grant_type=client_credentials" in content + assert "client_assertion=jwt-for-https://auth.example.com/" in content + assert "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" in content + assert "scope=read write" in content + + @pytest.mark.anyio + async def test_exchange_token_without_scopes(self, mock_storage: MockTokenStorage): + """Test token exchange without scopes.""" + + async def mock_assertion_provider(audience: str) -> str: + return f"jwt-for-{audience}" + + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com/v1/mcp", + storage=mock_storage, + client_id="test-client-id", + assertion_provider=mock_assertion_provider, + ) + provider.context.oauth_metadata = OAuthMetadata( + issuer=AnyHttpUrl("https://auth.example.com"), + authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://auth.example.com/token"), + ) + provider.context.protocol_version = "2024-11-05" # Old version - no resource param + + request = await provider._perform_authorization() + + content = urllib.parse.unquote_plus(request.content.decode()) + assert "grant_type=client_credentials" in content + assert "scope=" not in content + assert "resource=" not in content + + +class TestSignedJWTParameters: + """Test SignedJWTParameters.""" + + @pytest.mark.anyio + async def test_create_assertion_provider(self): + """Test that create_assertion_provider creates valid JWTs.""" + params = SignedJWTParameters( + issuer="test-issuer", + subject="test-subject", + signing_key="a-string-secret-at-least-256-bits-long", + signing_algorithm="HS256", + lifetime_seconds=300, + ) + + provider = params.create_assertion_provider() + assertion = await provider("https://auth.example.com") + + claims = jwt.decode( + assertion, + key="a-string-secret-at-least-256-bits-long", + algorithms=["HS256"], + audience="https://auth.example.com", + ) + assert claims["iss"] == "test-issuer" + assert claims["sub"] == "test-subject" + assert claims["aud"] == "https://auth.example.com" + assert "exp" in claims + assert "iat" in claims + assert "jti" in claims + + @pytest.mark.anyio + async def test_create_assertion_provider_with_additional_claims(self): + """Test that additional_claims are included in the JWT.""" + params = SignedJWTParameters( + issuer="test-issuer", + subject="test-subject", + signing_key="a-string-secret-at-least-256-bits-long", + signing_algorithm="HS256", + additional_claims={"custom": "value"}, + ) + + provider = params.create_assertion_provider() + assertion = await provider("https://auth.example.com") + + claims = jwt.decode( + assertion, + key="a-string-secret-at-least-256-bits-long", + algorithms=["HS256"], + audience="https://auth.example.com", + ) + assert claims["custom"] == "value" + + +class TestStaticAssertionProvider: + """Test static_assertion_provider helper.""" + + @pytest.mark.anyio + async def test_returns_static_token(self): + """Test that static_assertion_provider returns the same token regardless of audience.""" + token = "my-static-jwt-token" + provider = static_assertion_provider(token) + + result1 = await provider("https://auth1.example.com") + result2 = await provider("https://auth2.example.com") + + assert result1 == token + assert result2 == token diff --git a/tests/client/conftest.py b/tests/client/conftest.py new file mode 100644 index 0000000000..1e5c4d524c --- /dev/null +++ b/tests/client/conftest.py @@ -0,0 +1,133 @@ +from collections.abc import Callable, Generator +from contextlib import asynccontextmanager +from typing import Any +from unittest.mock import patch + +import pytest +from anyio.streams.memory import MemoryObjectSendStream + +import mcp.shared.memory +from mcp.shared.message import SessionMessage +from mcp.types import JSONRPCNotification, JSONRPCRequest + + +class SpyMemoryObjectSendStream: + def __init__(self, original_stream: MemoryObjectSendStream[SessionMessage]): + self.original_stream = original_stream + self.sent_messages: list[SessionMessage] = [] + + async def send(self, message: SessionMessage): + self.sent_messages.append(message) + await self.original_stream.send(message) + + async def aclose(self): + await self.original_stream.aclose() + + async def __aenter__(self): + return self + + async def __aexit__(self, *args: Any): + await self.aclose() + + +class StreamSpyCollection: + def __init__(self, client_spy: SpyMemoryObjectSendStream, server_spy: SpyMemoryObjectSendStream): + self.client = client_spy + self.server = server_spy + + def clear(self) -> None: + """Clear all captured messages.""" + self.client.sent_messages.clear() + self.server.sent_messages.clear() + + def get_client_requests(self, method: str | None = None) -> list[JSONRPCRequest]: # pragma: no cover + """Get client-sent requests, optionally filtered by method.""" + return [ + req.message.root + for req in self.client.sent_messages + if isinstance(req.message.root, JSONRPCRequest) and (method is None or req.message.root.method == method) + ] + + def get_server_requests(self, method: str | None = None) -> list[JSONRPCRequest]: # pragma: no cover + """Get server-sent requests, optionally filtered by method.""" + return [ # pragma: no cover + req.message.root + for req in self.server.sent_messages + if isinstance(req.message.root, JSONRPCRequest) and (method is None or req.message.root.method == method) + ] + + def get_client_notifications(self, method: str | None = None) -> list[JSONRPCNotification]: # pragma: no cover + """Get client-sent notifications, optionally filtered by method.""" + return [ + notif.message.root + for notif in self.client.sent_messages + if isinstance(notif.message.root, JSONRPCNotification) + and (method is None or notif.message.root.method == method) + ] + + def get_server_notifications(self, method: str | None = None) -> list[JSONRPCNotification]: # pragma: no cover + """Get server-sent notifications, optionally filtered by method.""" + return [ + notif.message.root + for notif in self.server.sent_messages + if isinstance(notif.message.root, JSONRPCNotification) + and (method is None or notif.message.root.method == method) + ] + + +@pytest.fixture +def stream_spy() -> Generator[Callable[[], StreamSpyCollection], None, None]: + """Fixture that provides spies for both client and server write streams. + + Example usage: + async def test_something(stream_spy): + # ... set up server and client ... + + spies = stream_spy() + + # Run some operation that sends messages + await client.some_operation() + + # Check the messages + requests = spies.get_client_requests(method="some/method") + assert len(requests) == 1 + + # Clear for the next operation + spies.clear() + """ + client_spy = None + server_spy = None + + # Store references to our spy objects + def capture_spies(c_spy: SpyMemoryObjectSendStream, s_spy: SpyMemoryObjectSendStream): + nonlocal client_spy, server_spy + client_spy = c_spy + server_spy = s_spy + + # Create patched version of stream creation + original_create_streams = mcp.shared.memory.create_client_server_memory_streams + + @asynccontextmanager + async def patched_create_streams(): + async with original_create_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + server_read, server_write = server_streams + + # Create spy wrappers + spy_client_write = SpyMemoryObjectSendStream(client_write) + spy_server_write = SpyMemoryObjectSendStream(server_write) + + # Capture references for the test to use + capture_spies(spy_client_write, spy_server_write) + + yield (client_read, spy_client_write), (server_read, spy_server_write) + + # Apply the patch for the duration of the test + with patch("mcp.shared.memory.create_client_server_memory_streams", patched_create_streams): + # Return a collection with helper methods + def get_spy_collection() -> StreamSpyCollection: + assert client_spy is not None, "client_spy was not initialized" + assert server_spy is not None, "server_spy was not initialized" + return StreamSpyCollection(client_spy, server_spy) + + yield get_spy_collection diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py new file mode 100644 index 0000000000..6025ff811b --- /dev/null +++ b/tests/client/test_auth.py @@ -0,0 +1,2142 @@ +""" +Tests for refactored OAuth client authentication implementation. +""" + +import base64 +import time +from unittest import mock +from urllib.parse import unquote + +import httpx +import pytest +from inline_snapshot import Is, snapshot +from pydantic import AnyHttpUrl, AnyUrl + +from mcp.client.auth import OAuthClientProvider, PKCEParameters +from mcp.client.auth.utils import ( + build_oauth_authorization_server_metadata_discovery_urls, + build_protected_resource_metadata_discovery_urls, + create_client_info_from_metadata_url, + create_client_registration_request, + create_oauth_metadata_request, + extract_field_from_www_auth, + extract_resource_metadata_from_www_auth, + extract_scope_from_www_auth, + get_client_metadata_scopes, + handle_registration_response, + is_valid_client_metadata_url, + should_use_client_metadata_url, +) +from mcp.shared.auth import ( + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthMetadata, + OAuthToken, + ProtectedResourceMetadata, +) + + +class MockTokenStorage: + """Mock token storage for testing.""" + + def __init__(self): + self._tokens: OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens # pragma: no cover + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info # pragma: no cover + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info + + +@pytest.fixture +def mock_storage(): + return MockTokenStorage() + + +@pytest.fixture +def client_metadata(): + return OAuthClientMetadata( + client_name="Test Client", + client_uri=AnyHttpUrl("https://example.com"), + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + scope="read write", + ) + + +@pytest.fixture +def valid_tokens(): + return OAuthToken( + access_token="test_access_token", + token_type="Bearer", + expires_in=3600, + refresh_token="test_refresh_token", + scope="read write", + ) + + +@pytest.fixture +def oauth_provider(client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage): + async def redirect_handler(url: str) -> None: + """Mock redirect handler.""" + pass # pragma: no cover + + async def callback_handler() -> tuple[str, str | None]: + """Mock callback handler.""" + return "test_auth_code", "test_state" # pragma: no cover + + return OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + + +@pytest.fixture +def prm_metadata_response(): + """PRM metadata response with scopes.""" + return httpx.Response( + 200, + content=( + b'{"resource": "https://api.example.com/v1/mcp", ' + b'"authorization_servers": ["https://auth.example.com"], ' + b'"scopes_supported": ["resource:read", "resource:write"]}' + ), + ) + + +@pytest.fixture +def prm_metadata_without_scopes_response(): + """PRM metadata response without scopes.""" + return httpx.Response( + 200, + content=( + b'{"resource": "https://api.example.com/v1/mcp", ' + b'"authorization_servers": ["https://auth.example.com"], ' + b'"scopes_supported": null}' + ), + ) + + +@pytest.fixture +def init_response_with_www_auth_scope(): + """Initial 401 response with WWW-Authenticate header containing scope.""" + return httpx.Response( + 401, + headers={"WWW-Authenticate": 'Bearer scope="special:scope from:www-authenticate"'}, + request=httpx.Request("GET", "https://api.example.com/test"), + ) + + +@pytest.fixture +def init_response_without_www_auth_scope(): + """Initial 401 response without WWW-Authenticate scope.""" + return httpx.Response( + 401, + headers={}, + request=httpx.Request("GET", "https://api.example.com/test"), + ) + + +class TestPKCEParameters: + """Test PKCE parameter generation.""" + + def test_pkce_generation(self): + """Test PKCE parameter generation creates valid values.""" + pkce = PKCEParameters.generate() + + # Verify lengths + assert len(pkce.code_verifier) == 128 + assert 43 <= len(pkce.code_challenge) <= 128 + + # Verify characters used in verifier + allowed_chars = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + assert all(c in allowed_chars for c in pkce.code_verifier) + + # Verify base64url encoding in challenge (no padding) + assert "=" not in pkce.code_challenge + + def test_pkce_uniqueness(self): + """Test PKCE generates unique values each time.""" + pkce1 = PKCEParameters.generate() + pkce2 = PKCEParameters.generate() + + assert pkce1.code_verifier != pkce2.code_verifier + assert pkce1.code_challenge != pkce2.code_challenge + + +class TestOAuthContext: + """Test OAuth context functionality.""" + + @pytest.mark.anyio + async def test_oauth_provider_initialization( + self, oauth_provider: OAuthClientProvider, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """Test OAuthClientProvider basic setup.""" + assert oauth_provider.context.server_url == "https://api.example.com/v1/mcp" + assert oauth_provider.context.client_metadata == client_metadata + assert oauth_provider.context.storage == mock_storage + assert oauth_provider.context.timeout == 300.0 + assert oauth_provider.context is not None + + def test_context_url_parsing(self, oauth_provider: OAuthClientProvider): + """Test get_authorization_base_url() extracts base URLs correctly.""" + context = oauth_provider.context + + # Test with path + assert context.get_authorization_base_url("https://api.example.com/v1/mcp") == "https://api.example.com" + + # Test with no path + assert context.get_authorization_base_url("https://api.example.com") == "https://api.example.com" + + # Test with port + assert ( + context.get_authorization_base_url("https://api.example.com:8080/path/to/mcp") + == "https://api.example.com:8080" + ) + + # Test with query params + assert ( + context.get_authorization_base_url("https://api.example.com/path?param=value") == "https://api.example.com" + ) + + @pytest.mark.anyio + async def test_token_validity_checking(self, oauth_provider: OAuthClientProvider, valid_tokens: OAuthToken): + """Test is_token_valid() and can_refresh_token() logic.""" + context = oauth_provider.context + + # No tokens - should be invalid + assert not context.is_token_valid() + assert not context.can_refresh_token() + + # Set valid tokens and client info + context.current_tokens = valid_tokens + context.token_expiry_time = time.time() + 1800 # 30 minutes from now + context.client_info = OAuthClientInformationFull( + client_id="test_client_id", + client_secret="test_client_secret", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + + # Should be valid + assert context.is_token_valid() + assert context.can_refresh_token() # Has refresh token and client info + + # Expire the token + context.token_expiry_time = time.time() - 100 # Expired 100 seconds ago + assert not context.is_token_valid() + assert context.can_refresh_token() # Can still refresh + + # Remove refresh token + context.current_tokens.refresh_token = None + assert not context.can_refresh_token() + + # Remove client info + context.current_tokens.refresh_token = "test_refresh_token" + context.client_info = None + assert not context.can_refresh_token() + + def test_clear_tokens(self, oauth_provider: OAuthClientProvider, valid_tokens: OAuthToken): + """Test clear_tokens() removes token data.""" + context = oauth_provider.context + context.current_tokens = valid_tokens + context.token_expiry_time = time.time() + 1800 + + # Clear tokens + context.clear_tokens() + + # Verify cleared + assert context.current_tokens is None + assert context.token_expiry_time is None + + +class TestOAuthFlow: + """Test OAuth flow methods.""" + + @pytest.mark.anyio + async def test_build_protected_resource_discovery_urls( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """Test protected resource metadata discovery URL building with fallback.""" + + async def redirect_handler(url: str) -> None: + pass # pragma: no cover + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "test_state" # pragma: no cover + + provider = OAuthClientProvider( + server_url="https://api.example.com", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + + # Test without WWW-Authenticate (fallback) + init_response = httpx.Response( + status_code=401, headers={}, request=httpx.Request("GET", "https://request-api.example.com") + ) + + urls = build_protected_resource_metadata_discovery_urls( + extract_resource_metadata_from_www_auth(init_response), provider.context.server_url + ) + assert len(urls) == 1 + assert urls[0] == "https://api.example.com/.well-known/oauth-protected-resource" + + # Test with WWW-Authenticate header + init_response.headers["WWW-Authenticate"] = ( + 'Bearer resource_metadata="https://prm.example.com/.well-known/oauth-protected-resource/path"' + ) + + urls = build_protected_resource_metadata_discovery_urls( + extract_resource_metadata_from_www_auth(init_response), provider.context.server_url + ) + assert len(urls) == 2 + assert urls[0] == "https://prm.example.com/.well-known/oauth-protected-resource/path" + assert urls[1] == "https://api.example.com/.well-known/oauth-protected-resource" + + @pytest.mark.anyio + def test_create_oauth_metadata_request(self, oauth_provider: OAuthClientProvider): + """Test OAuth metadata discovery request building.""" + request = create_oauth_metadata_request("https://example.com") + + # Ensure correct method and headers, and that the URL is unmodified + assert request.method == "GET" + assert str(request.url) == "https://example.com" + assert "mcp-protocol-version" in request.headers + + +class TestOAuthFallback: + """Test OAuth discovery fallback behavior for legacy (act as AS not RS) servers.""" + + @pytest.mark.anyio + async def test_oauth_discovery_legacy_fallback_when_no_prm(self): + """Test that when PRM discovery fails, only root OAuth URL is tried (March 2025 spec).""" + # When auth_server_url is None (PRM failed), we use server_url and only try root + discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(None, "https://mcp.linear.app/sse") + + # Should only try the root URL (legacy behavior) + assert discovery_urls == [ + "https://mcp.linear.app/.well-known/oauth-authorization-server", + ] + + @pytest.mark.anyio + async def test_oauth_discovery_path_aware_when_auth_server_has_path(self): + """Test that when auth server URL has a path, only path-based URLs are tried.""" + discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + "https://auth.example.com/tenant1", "https://api.example.com/mcp" + ) + + # Should try path-based URLs only (no root URLs) + assert discovery_urls == [ + "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", + "https://auth.example.com/.well-known/openid-configuration/tenant1", + "https://auth.example.com/tenant1/.well-known/openid-configuration", + ] + + @pytest.mark.anyio + async def test_oauth_discovery_root_when_auth_server_has_no_path(self): + """Test that when auth server URL has no path, only root URLs are tried.""" + discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + "https://auth.example.com", "https://api.example.com/mcp" + ) + + # Should try root URLs only + assert discovery_urls == [ + "https://auth.example.com/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration", + ] + + @pytest.mark.anyio + async def test_oauth_discovery_root_when_auth_server_has_only_slash(self): + """Test that when auth server URL has only trailing slash, treated as root.""" + discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + "https://auth.example.com/", "https://api.example.com/mcp" + ) + + # Should try root URLs only + assert discovery_urls == [ + "https://auth.example.com/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration", + ] + + @pytest.mark.anyio + async def test_oauth_discovery_fallback_order(self, oauth_provider: OAuthClientProvider): + """Test fallback URL construction order when auth server URL has a path.""" + # Simulate PRM discovery returning an auth server URL with a path + oauth_provider.context.auth_server_url = oauth_provider.context.server_url + + discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( + oauth_provider.context.auth_server_url, oauth_provider.context.server_url + ) + + assert discovery_urls == [ + "https://api.example.com/.well-known/oauth-authorization-server/v1/mcp", + "https://api.example.com/.well-known/openid-configuration/v1/mcp", + "https://api.example.com/v1/mcp/.well-known/openid-configuration", + ] + + @pytest.mark.anyio + async def test_oauth_discovery_fallback_conditions(self, oauth_provider: OAuthClientProvider): + """Test the conditions during which an AS metadata discovery fallback will be attempted.""" + # Ensure no tokens are stored + oauth_provider.context.current_tokens = None + oauth_provider.context.token_expiry_time = None + oauth_provider._initialized = True + + # Mock client info to skip DCR + oauth_provider.context.client_info = OAuthClientInformationFull( + client_id="existing_client", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + + # Create a test request + test_request = httpx.Request("GET", "https://api.example.com/v1/mcp") + + # Mock the auth flow + auth_flow = oauth_provider.async_auth_flow(test_request) + + # First request should be the original request without auth header + request = await auth_flow.__anext__() + assert "Authorization" not in request.headers + + # Send a 401 response to trigger the OAuth flow + response = httpx.Response( + 401, + headers={ + "WWW-Authenticate": 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"' + }, + request=test_request, + ) + + # Next request should be to discover protected resource metadata + discovery_request = await auth_flow.asend(response) + assert str(discovery_request.url) == "https://api.example.com/.well-known/oauth-protected-resource" + assert discovery_request.method == "GET" + + # Send a successful discovery response with minimal protected resource metadata + # Note: auth server URL has a path (/v1/mcp), so only path-based URLs will be tried + discovery_response = httpx.Response( + 200, + content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com/v1/mcp"]}', + request=discovery_request, + ) + + # Next request should be to discover OAuth metadata at path-aware OAuth URL + oauth_metadata_request_1 = await auth_flow.asend(discovery_response) + assert ( + str(oauth_metadata_request_1.url) + == "https://auth.example.com/.well-known/oauth-authorization-server/v1/mcp" + ) + assert oauth_metadata_request_1.method == "GET" + + # Send a 404 response + oauth_metadata_response_1 = httpx.Response( + 404, + content=b"Not Found", + request=oauth_metadata_request_1, + ) + + # Next request should be path-aware OIDC URL (not root URL since auth server has path) + oauth_metadata_request_2 = await auth_flow.asend(oauth_metadata_response_1) + assert str(oauth_metadata_request_2.url) == "https://auth.example.com/.well-known/openid-configuration/v1/mcp" + assert oauth_metadata_request_2.method == "GET" + + # Send a 400 response + oauth_metadata_response_2 = httpx.Response( + 400, + content=b"Bad Request", + request=oauth_metadata_request_2, + ) + + # Next request should be OIDC path-appended URL + oauth_metadata_request_3 = await auth_flow.asend(oauth_metadata_response_2) + assert str(oauth_metadata_request_3.url) == "https://auth.example.com/v1/mcp/.well-known/openid-configuration" + assert oauth_metadata_request_3.method == "GET" + + # Send a 500 response + oauth_metadata_response_3 = httpx.Response( + 500, + content=b"Internal Server Error", + request=oauth_metadata_request_3, + ) + + # Mock the authorization process to minimize unnecessary state in this test + oauth_provider._perform_authorization_code_grant = mock.AsyncMock( + return_value=("test_auth_code", "test_code_verifier") + ) + + # All path-based URLs failed, flow continues with default endpoints + # Next request should be token exchange using MCP server base URL (fallback when OAuth metadata not found) + token_request = await auth_flow.asend(oauth_metadata_response_3) + assert str(token_request.url) == "https://api.example.com/token" + assert token_request.method == "POST" + + # Send a successful token response + token_response = httpx.Response( + 200, + content=( + b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600, ' + b'"refresh_token": "new_refresh_token"}' + ), + request=token_request, + ) + + # After OAuth flow completes, the original request is retried with auth header + final_request = await auth_flow.asend(token_response) + assert final_request.headers["Authorization"] == "Bearer new_access_token" + assert final_request.method == "GET" + assert str(final_request.url) == "https://api.example.com/v1/mcp" + + # Send final success response to properly close the generator + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass # Expected - generator should complete + + @pytest.mark.anyio + async def test_handle_metadata_response_success(self, oauth_provider: OAuthClientProvider): + """Test successful metadata response handling.""" + # Create minimal valid OAuth metadata + content = b"""{ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }""" + response = httpx.Response(200, content=content) + + # Should set metadata + await oauth_provider._handle_oauth_metadata_response(response) + assert oauth_provider.context.oauth_metadata is not None + assert str(oauth_provider.context.oauth_metadata.issuer) == "https://auth.example.com/" + + @pytest.mark.anyio + async def test_prioritize_www_auth_scope_over_prm( + self, + oauth_provider: OAuthClientProvider, + prm_metadata_response: httpx.Response, + init_response_with_www_auth_scope: httpx.Response, + ): + """Test that WWW-Authenticate scope is prioritized over PRM scopes.""" + # First, process PRM metadata to set protected_resource_metadata with scopes + await oauth_provider._handle_protected_resource_response(prm_metadata_response) + + # Process the scope selection with WWW-Authenticate header + scopes = get_client_metadata_scopes( + extract_scope_from_www_auth(init_response_with_www_auth_scope), + oauth_provider.context.protected_resource_metadata, + ) + + # Verify that WWW-Authenticate scope is used (not PRM scopes) + assert scopes == "special:scope from:www-authenticate" + + @pytest.mark.anyio + async def test_prioritize_prm_scopes_when_no_www_auth_scope( + self, + oauth_provider: OAuthClientProvider, + prm_metadata_response: httpx.Response, + init_response_without_www_auth_scope: httpx.Response, + ): + """Test that PRM scopes are prioritized when WWW-Authenticate header has no scopes.""" + # Process the PRM metadata to set protected_resource_metadata with scopes + await oauth_provider._handle_protected_resource_response(prm_metadata_response) + + # Process the scope selection without WWW-Authenticate scope + scopes = get_client_metadata_scopes( + extract_scope_from_www_auth(init_response_without_www_auth_scope), + oauth_provider.context.protected_resource_metadata, + ) + + # Verify that PRM scopes are used + assert scopes == "resource:read resource:write" + + @pytest.mark.anyio + async def test_omit_scope_when_no_prm_scopes_or_www_auth( + self, + oauth_provider: OAuthClientProvider, + prm_metadata_without_scopes_response: httpx.Response, + init_response_without_www_auth_scope: httpx.Response, + ): + """Test that scope is omitted when PRM has no scopes and WWW-Authenticate doesn't specify scope.""" + # Process the PRM metadata without scopes + await oauth_provider._handle_protected_resource_response(prm_metadata_without_scopes_response) + + # Process the scope selection without WWW-Authenticate scope + scopes = get_client_metadata_scopes( + extract_scope_from_www_auth(init_response_without_www_auth_scope), + oauth_provider.context.protected_resource_metadata, + ) + # Verify that scope is omitted + assert scopes is None + + @pytest.mark.anyio + async def test_token_exchange_request_authorization_code(self, oauth_provider: OAuthClientProvider): + """Test token exchange request building.""" + # Set up required context + oauth_provider.context.client_info = OAuthClientInformationFull( + client_id="test_client", + client_secret="test_secret", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + token_endpoint_auth_method="client_secret_post", + ) + + request = await oauth_provider._exchange_token_authorization_code("test_auth_code", "test_verifier") + + assert request.method == "POST" + assert str(request.url) == "https://api.example.com/token" + assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" + + # Check form data + content = request.content.decode() + assert "grant_type=authorization_code" in content + assert "code=test_auth_code" in content + assert "code_verifier=test_verifier" in content + assert "client_id=test_client" in content + assert "client_secret=test_secret" in content + + @pytest.mark.anyio + async def test_refresh_token_request(self, oauth_provider: OAuthClientProvider, valid_tokens: OAuthToken): + """Test refresh token request building.""" + # Set up required context + oauth_provider.context.current_tokens = valid_tokens + oauth_provider.context.client_info = OAuthClientInformationFull( + client_id="test_client", + client_secret="test_secret", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + token_endpoint_auth_method="client_secret_post", + ) + + request = await oauth_provider._refresh_token() + + assert request.method == "POST" + assert str(request.url) == "https://api.example.com/token" + assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" + + # Check form data + content = request.content.decode() + assert "grant_type=refresh_token" in content + assert "refresh_token=test_refresh_token" in content + assert "client_id=test_client" in content + assert "client_secret=test_secret" in content + + @pytest.mark.anyio + async def test_basic_auth_token_exchange(self, oauth_provider: OAuthClientProvider): + """Test token exchange with client_secret_basic authentication.""" + # Set up OAuth metadata to support basic auth + oauth_provider.context.oauth_metadata = OAuthMetadata( + issuer=AnyHttpUrl("https://auth.example.com"), + authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://auth.example.com/token"), + token_endpoint_auth_methods_supported=["client_secret_basic", "client_secret_post"], + ) + + client_id_raw = "test@client" # Include special character to test URL encoding + client_secret_raw = "test:secret" # Include colon to test URL encoding + + oauth_provider.context.client_info = OAuthClientInformationFull( + client_id=client_id_raw, + client_secret=client_secret_raw, + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + token_endpoint_auth_method="client_secret_basic", + ) + + request = await oauth_provider._exchange_token_authorization_code("test_auth_code", "test_verifier") + + # Should use basic auth (registered method) + assert "Authorization" in request.headers + assert request.headers["Authorization"].startswith("Basic ") + + # Decode and verify credentials are properly URL-encoded + encoded_creds = request.headers["Authorization"][6:] # Remove "Basic " prefix + decoded = base64.b64decode(encoded_creds).decode() + client_id, client_secret = decoded.split(":", 1) + + # Check URL encoding was applied + assert client_id == "test%40client" # @ should be encoded as %40 + assert client_secret == "test%3Asecret" # : should be encoded as %3A + + # Verify decoded values match original + assert unquote(client_id) == client_id_raw + assert unquote(client_secret) == client_secret_raw + + # client_secret should NOT be in body for basic auth + content = request.content.decode() + assert "client_secret=" not in content + assert "client_id=test%40client" in content # client_id still in body + + @pytest.mark.anyio + async def test_basic_auth_refresh_token(self, oauth_provider: OAuthClientProvider, valid_tokens: OAuthToken): + """Test token refresh with client_secret_basic authentication.""" + oauth_provider.context.current_tokens = valid_tokens + + # Set up OAuth metadata to only support basic auth + oauth_provider.context.oauth_metadata = OAuthMetadata( + issuer=AnyHttpUrl("https://auth.example.com"), + authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://auth.example.com/token"), + token_endpoint_auth_methods_supported=["client_secret_basic"], + ) + + client_id = "test_client" + client_secret = "test_secret" + oauth_provider.context.client_info = OAuthClientInformationFull( + client_id=client_id, + client_secret=client_secret, + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + token_endpoint_auth_method="client_secret_basic", + ) + + request = await oauth_provider._refresh_token() + + assert "Authorization" in request.headers + assert request.headers["Authorization"].startswith("Basic ") + + encoded_creds = request.headers["Authorization"][6:] + decoded = base64.b64decode(encoded_creds).decode() + assert decoded == f"{client_id}:{client_secret}" + + # client_secret should NOT be in body + content = request.content.decode() + assert "client_secret=" not in content + + @pytest.mark.anyio + async def test_none_auth_method(self, oauth_provider: OAuthClientProvider): + """Test 'none' authentication method (public client).""" + oauth_provider.context.oauth_metadata = OAuthMetadata( + issuer=AnyHttpUrl("https://auth.example.com"), + authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://auth.example.com/token"), + token_endpoint_auth_methods_supported=["none"], + ) + + client_id = "public_client" + oauth_provider.context.client_info = OAuthClientInformationFull( + client_id=client_id, + client_secret=None, # No secret for public client + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + token_endpoint_auth_method="none", + ) + + request = await oauth_provider._exchange_token_authorization_code("test_auth_code", "test_verifier") + + # Should NOT have Authorization header + assert "Authorization" not in request.headers + + # Should NOT have client_secret in body + content = request.content.decode() + assert "client_secret=" not in content + assert "client_id=public_client" in content + + +class TestProtectedResourceMetadata: + """Test protected resource handling.""" + + @pytest.mark.anyio + async def test_resource_param_included_with_recent_protocol_version(self, oauth_provider: OAuthClientProvider): + """Test resource parameter is included for protocol version >= 2025-06-18.""" + # Set protocol version to 2025-06-18 + oauth_provider.context.protocol_version = "2025-06-18" + oauth_provider.context.client_info = OAuthClientInformationFull( + client_id="test_client", + client_secret="test_secret", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + + # Test in token exchange + request = await oauth_provider._exchange_token_authorization_code("test_code", "test_verifier") + content = request.content.decode() + assert "resource=" in content + # Check URL-encoded resource parameter + from urllib.parse import quote + + expected_resource = quote(oauth_provider.context.get_resource_url(), safe="") + assert f"resource={expected_resource}" in content + + # Test in refresh token + oauth_provider.context.current_tokens = OAuthToken( + access_token="test_access", + token_type="Bearer", + refresh_token="test_refresh", + ) + refresh_request = await oauth_provider._refresh_token() + refresh_content = refresh_request.content.decode() + assert "resource=" in refresh_content + + @pytest.mark.anyio + async def test_resource_param_excluded_with_old_protocol_version(self, oauth_provider: OAuthClientProvider): + """Test resource parameter is excluded for protocol version < 2025-06-18.""" + # Set protocol version to older version + oauth_provider.context.protocol_version = "2025-03-26" + oauth_provider.context.client_info = OAuthClientInformationFull( + client_id="test_client", + client_secret="test_secret", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + + # Test in token exchange + request = await oauth_provider._exchange_token_authorization_code("test_code", "test_verifier") + content = request.content.decode() + assert "resource=" not in content + + # Test in refresh token + oauth_provider.context.current_tokens = OAuthToken( + access_token="test_access", + token_type="Bearer", + refresh_token="test_refresh", + ) + refresh_request = await oauth_provider._refresh_token() + refresh_content = refresh_request.content.decode() + assert "resource=" not in refresh_content + + @pytest.mark.anyio + async def test_resource_param_included_with_protected_resource_metadata(self, oauth_provider: OAuthClientProvider): + """Test resource parameter is always included when protected resource metadata exists.""" + # Set old protocol version but with protected resource metadata + oauth_provider.context.protocol_version = "2025-03-26" + oauth_provider.context.protected_resource_metadata = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/v1/mcp"), + authorization_servers=[AnyHttpUrl("https://api.example.com")], + ) + oauth_provider.context.client_info = OAuthClientInformationFull( + client_id="test_client", + client_secret="test_secret", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + + # Test in token exchange + request = await oauth_provider._exchange_token_authorization_code("test_code", "test_verifier") + content = request.content.decode() + assert "resource=" in content + + +class TestRegistrationResponse: + """Test client registration response handling.""" + + @pytest.mark.anyio + async def test_handle_registration_response_reads_before_accessing_text(self): + """Test that response.aread() is called before accessing response.text.""" + + # Track if aread() was called + class MockResponse(httpx.Response): + def __init__(self): + self.status_code = 400 + self._aread_called = False + self._text = "Registration failed with error" + + async def aread(self): + self._aread_called = True + return b"test content" + + @property + def text(self): + if not self._aread_called: + raise RuntimeError("Response.text accessed before response.aread()") # pragma: no cover + return self._text + + mock_response = MockResponse() + + # This should call aread() before accessing text + with pytest.raises(Exception) as exc_info: + await handle_registration_response(mock_response) + + # Verify aread() was called + assert mock_response._aread_called + # Verify the error message includes the response text + assert "Registration failed: 400" in str(exc_info.value) + + +class TestCreateClientRegistrationRequest: + """Test client registration request creation.""" + + def test_uses_registration_endpoint_from_metadata(self): + """Test that registration URL comes from metadata when available.""" + oauth_metadata = OAuthMetadata( + issuer=AnyHttpUrl("https://auth.example.com"), + authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://auth.example.com/token"), + registration_endpoint=AnyHttpUrl("https://auth.example.com/register"), + ) + client_metadata = OAuthClientMetadata(redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")]) + + request = create_client_registration_request(oauth_metadata, client_metadata, "https://auth.example.com") + + assert str(request.url) == "https://auth.example.com/register" + assert request.method == "POST" + + def test_falls_back_to_default_register_endpoint_when_no_metadata(self): + """Test that registration uses fallback URL when auth_server_metadata is None.""" + client_metadata = OAuthClientMetadata(redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")]) + + request = create_client_registration_request(None, client_metadata, "https://auth.example.com") + + assert str(request.url) == "https://auth.example.com/register" + assert request.method == "POST" + + def test_falls_back_when_metadata_has_no_registration_endpoint(self): + """Test fallback when metadata exists but lacks registration_endpoint.""" + oauth_metadata = OAuthMetadata( + issuer=AnyHttpUrl("https://auth.example.com"), + authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://auth.example.com/token"), + # No registration_endpoint + ) + client_metadata = OAuthClientMetadata(redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")]) + + request = create_client_registration_request(oauth_metadata, client_metadata, "https://auth.example.com") + + assert str(request.url) == "https://auth.example.com/register" + assert request.method == "POST" + + +class TestAuthFlow: + """Test the auth flow in httpx.""" + + @pytest.mark.anyio + async def test_auth_flow_with_valid_tokens( + self, oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, valid_tokens: OAuthToken + ): + """Test auth flow when tokens are already valid.""" + # Pre-store valid tokens + await mock_storage.set_tokens(valid_tokens) + oauth_provider.context.current_tokens = valid_tokens + oauth_provider.context.token_expiry_time = time.time() + 1800 + oauth_provider._initialized = True + + # Create a test request + test_request = httpx.Request("GET", "https://api.example.com/test") + + # Mock the auth flow + auth_flow = oauth_provider.async_auth_flow(test_request) + + # Should get the request with auth header added + request = await auth_flow.__anext__() + assert request.headers["Authorization"] == "Bearer test_access_token" + + # Send a successful response + response = httpx.Response(200) + try: + await auth_flow.asend(response) + except StopAsyncIteration: + pass # Expected + + @pytest.mark.anyio + async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage): + """Test auth flow when no tokens are available, triggering the full OAuth flow.""" + # Ensure no tokens are stored + oauth_provider.context.current_tokens = None + oauth_provider.context.token_expiry_time = None + oauth_provider._initialized = True + + # Create a test request + test_request = httpx.Request("GET", "https://api.example.com/mcp") + + # Mock the auth flow + auth_flow = oauth_provider.async_auth_flow(test_request) + + # First request should be the original request without auth header + request = await auth_flow.__anext__() + assert "Authorization" not in request.headers + + # Send a 401 response to trigger the OAuth flow + response = httpx.Response( + 401, + headers={ + "WWW-Authenticate": 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"' + }, + request=test_request, + ) + + # Next request should be to discover protected resource metadata + discovery_request = await auth_flow.asend(response) + assert discovery_request.method == "GET" + assert str(discovery_request.url) == "https://api.example.com/.well-known/oauth-protected-resource" + + # Send a successful discovery response with minimal protected resource metadata + discovery_response = httpx.Response( + 200, + content=b'{"resource": "https://api.example.com/mcp", "authorization_servers": ["https://auth.example.com"]}', + request=discovery_request, + ) + + # Next request should be to discover OAuth metadata + oauth_metadata_request = await auth_flow.asend(discovery_response) + assert oauth_metadata_request.method == "GET" + assert str(oauth_metadata_request.url).startswith("https://auth.example.com/") + assert "mcp-protocol-version" in oauth_metadata_request.headers + + # Send a successful OAuth metadata response + oauth_metadata_response = httpx.Response( + 200, + content=( + b'{"issuer": "https://auth.example.com", ' + b'"authorization_endpoint": "https://auth.example.com/authorize", ' + b'"token_endpoint": "https://auth.example.com/token", ' + b'"registration_endpoint": "https://auth.example.com/register"}' + ), + request=oauth_metadata_request, + ) + + # Next request should be to register client + registration_request = await auth_flow.asend(oauth_metadata_response) + assert registration_request.method == "POST" + assert str(registration_request.url) == "https://auth.example.com/register" + + # Send a successful registration response + registration_response = httpx.Response( + 201, + content=b'{"client_id": "test_client_id", "client_secret": "test_client_secret", "redirect_uris": ["http://localhost:3030/callback"]}', + request=registration_request, + ) + + # Mock the authorization process + oauth_provider._perform_authorization_code_grant = mock.AsyncMock( + return_value=("test_auth_code", "test_code_verifier") + ) + + # Next request should be to exchange token + token_request = await auth_flow.asend(registration_response) + assert token_request.method == "POST" + assert str(token_request.url) == "https://auth.example.com/token" + assert "code=test_auth_code" in token_request.content.decode() + + # Send a successful token response + token_response = httpx.Response( + 200, + content=( + b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600, ' + b'"refresh_token": "new_refresh_token"}' + ), + request=token_request, + ) + + # Final request should be the original request with auth header + final_request = await auth_flow.asend(token_response) + assert final_request.headers["Authorization"] == "Bearer new_access_token" + assert final_request.method == "GET" + assert str(final_request.url) == "https://api.example.com/mcp" + + # Send final success response to properly close the generator + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass # Expected - generator should complete + + # Verify tokens were stored + assert oauth_provider.context.current_tokens is not None + assert oauth_provider.context.current_tokens.access_token == "new_access_token" + assert oauth_provider.context.token_expiry_time is not None + + @pytest.mark.anyio + async def test_auth_flow_no_unnecessary_retry_after_oauth( + self, oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, valid_tokens: OAuthToken + ): + """Test that requests are not retried unnecessarily - the core bug that caused 2x performance degradation.""" + # Pre-store valid tokens so no OAuth flow is needed + await mock_storage.set_tokens(valid_tokens) + oauth_provider.context.current_tokens = valid_tokens + oauth_provider.context.token_expiry_time = time.time() + 1800 + oauth_provider._initialized = True + + test_request = httpx.Request("GET", "https://api.example.com/mcp") + auth_flow = oauth_provider.async_auth_flow(test_request) + + # Count how many times the request is yielded + request_yields = 0 + + # First request - should have auth header already + request = await auth_flow.__anext__() + request_yields += 1 + assert request.headers["Authorization"] == "Bearer test_access_token" + + # Send a successful 200 response + response = httpx.Response(200, request=request) + + # In the buggy version, this would yield the request AGAIN unconditionally + # In the fixed version, this should end the generator + try: + await auth_flow.asend(response) # extra request + request_yields += 1 # pragma: no cover + # If we reach here, the bug is present + pytest.fail( + f"Unnecessary retry detected! Request was yielded {request_yields} times. " + f"This indicates the retry logic bug that caused 2x performance degradation. " + f"The request should only be yielded once for successful responses." + ) # pragma: no cover + except StopAsyncIteration: + # This is the expected behavior - no unnecessary retry + pass + + # Verify exactly one request was yielded (no double-sending) + assert request_yields == 1, f"Expected 1 request yield, got {request_yields}" + + @pytest.mark.anyio + async def test_token_exchange_accepts_201_status( + self, oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage + ): + """Test that token exchange accepts both 200 and 201 status codes.""" + # Ensure no tokens are stored + oauth_provider.context.current_tokens = None + oauth_provider.context.token_expiry_time = None + oauth_provider._initialized = True + + # Create a test request + test_request = httpx.Request("GET", "https://api.example.com/mcp") + + # Mock the auth flow + auth_flow = oauth_provider.async_auth_flow(test_request) + + # First request should be the original request without auth header + request = await auth_flow.__anext__() + assert "Authorization" not in request.headers + + # Send a 401 response to trigger the OAuth flow + response = httpx.Response( + 401, + headers={ + "WWW-Authenticate": 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"' + }, + request=test_request, + ) + + # Next request should be to discover protected resource metadata + discovery_request = await auth_flow.asend(response) + assert discovery_request.method == "GET" + assert str(discovery_request.url) == "https://api.example.com/.well-known/oauth-protected-resource" + + # Send a successful discovery response with minimal protected resource metadata + discovery_response = httpx.Response( + 200, + content=b'{"resource": "https://api.example.com/mcp", "authorization_servers": ["https://auth.example.com"]}', + request=discovery_request, + ) + + # Next request should be to discover OAuth metadata + oauth_metadata_request = await auth_flow.asend(discovery_response) + assert oauth_metadata_request.method == "GET" + assert str(oauth_metadata_request.url).startswith("https://auth.example.com/") + assert "mcp-protocol-version" in oauth_metadata_request.headers + + # Send a successful OAuth metadata response + oauth_metadata_response = httpx.Response( + 200, + content=( + b'{"issuer": "https://auth.example.com", ' + b'"authorization_endpoint": "https://auth.example.com/authorize", ' + b'"token_endpoint": "https://auth.example.com/token", ' + b'"registration_endpoint": "https://auth.example.com/register"}' + ), + request=oauth_metadata_request, + ) + + # Next request should be to register client + registration_request = await auth_flow.asend(oauth_metadata_response) + assert registration_request.method == "POST" + assert str(registration_request.url) == "https://auth.example.com/register" + + # Send a successful registration response with 201 status + registration_response = httpx.Response( + 201, + content=b'{"client_id": "test_client_id", "client_secret": "test_client_secret", "redirect_uris": ["http://localhost:3030/callback"]}', + request=registration_request, + ) + + # Mock the authorization process + oauth_provider._perform_authorization_code_grant = mock.AsyncMock( + return_value=("test_auth_code", "test_code_verifier") + ) + + # Next request should be to exchange token + token_request = await auth_flow.asend(registration_response) + assert token_request.method == "POST" + assert str(token_request.url) == "https://auth.example.com/token" + assert "code=test_auth_code" in token_request.content.decode() + + # Send a successful token response with 201 status code (test both 200 and 201 are accepted) + token_response = httpx.Response( + 201, + content=( + b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600, ' + b'"refresh_token": "new_refresh_token"}' + ), + request=token_request, + ) + + # Final request should be the original request with auth header + final_request = await auth_flow.asend(token_response) + assert final_request.headers["Authorization"] == "Bearer new_access_token" + assert final_request.method == "GET" + assert str(final_request.url) == "https://api.example.com/mcp" + + # Send final success response to properly close the generator + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass # Expected - generator should complete + + # Verify tokens were stored + assert oauth_provider.context.current_tokens is not None + assert oauth_provider.context.current_tokens.access_token == "new_access_token" + assert oauth_provider.context.token_expiry_time is not None + + @pytest.mark.anyio + async def test_403_insufficient_scope_updates_scope_from_header( + self, + oauth_provider: OAuthClientProvider, + mock_storage: MockTokenStorage, + valid_tokens: OAuthToken, + ): + """Test that 403 response correctly updates scope from WWW-Authenticate header.""" + # Pre-store valid tokens and client info + client_info = OAuthClientInformationFull( + client_id="test_client_id", + client_secret="test_client_secret", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + await mock_storage.set_tokens(valid_tokens) + await mock_storage.set_client_info(client_info) + oauth_provider.context.current_tokens = valid_tokens + oauth_provider.context.token_expiry_time = time.time() + 1800 + oauth_provider.context.client_info = client_info + oauth_provider._initialized = True + + # Original scope + assert oauth_provider.context.client_metadata.scope == "read write" + + redirect_captured = False + captured_state = None + + async def capture_redirect(url: str) -> None: + nonlocal redirect_captured, captured_state + redirect_captured = True + # Verify the new scope is included in authorization URL + assert "scope=admin%3Awrite+admin%3Adelete" in url or "scope=admin:write+admin:delete" in url.replace( + "%3A", ":" + ).replace("+", " ") + # Extract state from redirect URL + from urllib.parse import parse_qs, urlparse + + parsed = urlparse(url) + params = parse_qs(parsed.query) + captured_state = params.get("state", [None])[0] + + oauth_provider.context.redirect_handler = capture_redirect + + # Mock callback + async def mock_callback() -> tuple[str, str | None]: + return "auth_code", captured_state + + oauth_provider.context.callback_handler = mock_callback + + test_request = httpx.Request("GET", "https://api.example.com/mcp") + auth_flow = oauth_provider.async_auth_flow(test_request) + + # First request + request = await auth_flow.__anext__() + + # Send 403 with new scope requirement + response_403 = httpx.Response( + 403, + headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="admin:write admin:delete"'}, + request=request, + ) + + # Trigger step-up - should get token exchange request + token_exchange_request = await auth_flow.asend(response_403) + + # Verify scope was updated + assert oauth_provider.context.client_metadata.scope == "admin:write admin:delete" + assert redirect_captured + + # Complete the flow with successful token response + token_response = httpx.Response( + 200, + json={ + "access_token": "new_token_with_new_scope", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "admin:write admin:delete", + }, + request=token_exchange_request, + ) + + # Should get final retry request + final_request = await auth_flow.asend(token_response) + + # Send success response - flow should complete + success_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(success_response) + pytest.fail("Should have stopped after successful response") # pragma: no cover + except StopAsyncIteration: + pass # Expected + + +@pytest.mark.parametrize( + ( + "issuer_url", + "service_documentation_url", + "authorization_endpoint", + "token_endpoint", + "registration_endpoint", + "revocation_endpoint", + ), + ( + # Pydantic's AnyUrl incorrectly adds trailing slash to base URLs + # This is being fixed in https://github.com/pydantic/pydantic-core/pull/1719 (Pydantic 2.12+) + pytest.param( + "https://auth.example.com", + "https://auth.example.com/docs", + "https://auth.example.com/authorize", + "https://auth.example.com/token", + "https://auth.example.com/register", + "https://auth.example.com/revoke", + id="simple-url", + marks=pytest.mark.xfail( + reason="Pydantic AnyUrl adds trailing slash to base URLs - fixed in Pydantic 2.12+" + ), + ), + pytest.param( + "https://auth.example.com/", + "https://auth.example.com/docs", + "https://auth.example.com/authorize", + "https://auth.example.com/token", + "https://auth.example.com/register", + "https://auth.example.com/revoke", + id="with-trailing-slash", + ), + pytest.param( + "https://auth.example.com/v1/mcp", + "https://auth.example.com/v1/mcp/docs", + "https://auth.example.com/v1/mcp/authorize", + "https://auth.example.com/v1/mcp/token", + "https://auth.example.com/v1/mcp/register", + "https://auth.example.com/v1/mcp/revoke", + id="with-path-param", + ), + ), +) +def test_build_metadata( + issuer_url: str, + service_documentation_url: str, + authorization_endpoint: str, + token_endpoint: str, + registration_endpoint: str, + revocation_endpoint: str, +): + from mcp.server.auth.routes import build_metadata + from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions + + metadata = build_metadata( + issuer_url=AnyHttpUrl(issuer_url), + service_documentation_url=AnyHttpUrl(service_documentation_url), + client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=["read", "write", "admin"]), + revocation_options=RevocationOptions(enabled=True), + ) + + assert metadata.model_dump(exclude_defaults=True, mode="json") == snapshot( + { + "issuer": Is(issuer_url), + "authorization_endpoint": Is(authorization_endpoint), + "token_endpoint": Is(token_endpoint), + "registration_endpoint": Is(registration_endpoint), + "scopes_supported": ["read", "write", "admin"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "service_documentation": Is(service_documentation_url), + "revocation_endpoint": Is(revocation_endpoint), + "revocation_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "code_challenge_methods_supported": ["S256"], + } + ) + + +class TestLegacyServerFallback: + """Test backward compatibility with legacy servers that don't support PRM (issue #1495).""" + + @pytest.mark.anyio + async def test_legacy_server_no_prm_falls_back_to_root_oauth_discovery( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """Test that when PRM discovery fails completely, we fall back to root OAuth discovery (March 2025 spec).""" + + async def redirect_handler(url: str) -> None: + pass # pragma: no cover + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "test_state" # pragma: no cover + + # Simulate a legacy server like Linear + provider = OAuthClientProvider( + server_url="https://mcp.linear.app/sse", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + + provider.context.current_tokens = None + provider.context.token_expiry_time = None + provider._initialized = True + + # Mock client info to skip DCR + provider.context.client_info = OAuthClientInformationFull( + client_id="existing_client", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + + test_request = httpx.Request("GET", "https://mcp.linear.app/sse") + auth_flow = provider.async_auth_flow(test_request) + + # First request + request = await auth_flow.__anext__() + assert "Authorization" not in request.headers + + # Send 401 without WWW-Authenticate header (typical legacy server) + response = httpx.Response(401, headers={}, request=test_request) + + # Should try path-based PRM first + prm_request_1 = await auth_flow.asend(response) + assert str(prm_request_1.url) == "https://mcp.linear.app/.well-known/oauth-protected-resource/sse" + + # PRM returns 404 + prm_response_1 = httpx.Response(404, request=prm_request_1) + + # Should try root-based PRM + prm_request_2 = await auth_flow.asend(prm_response_1) + assert str(prm_request_2.url) == "https://mcp.linear.app/.well-known/oauth-protected-resource" + + # PRM returns 404 again - all PRM URLs failed + prm_response_2 = httpx.Response(404, request=prm_request_2) + + # Should fall back to root OAuth discovery (March 2025 spec behavior) + oauth_metadata_request = await auth_flow.asend(prm_response_2) + assert str(oauth_metadata_request.url) == "https://mcp.linear.app/.well-known/oauth-authorization-server" + assert oauth_metadata_request.method == "GET" + + # Send successful OAuth metadata response + oauth_metadata_response = httpx.Response( + 200, + content=( + b'{"issuer": "https://mcp.linear.app", ' + b'"authorization_endpoint": "https://mcp.linear.app/authorize", ' + b'"token_endpoint": "https://mcp.linear.app/token"}' + ), + request=oauth_metadata_request, + ) + + # Mock authorization + provider._perform_authorization_code_grant = mock.AsyncMock( + return_value=("test_auth_code", "test_code_verifier") + ) + + # Next should be token exchange + token_request = await auth_flow.asend(oauth_metadata_response) + assert str(token_request.url) == "https://mcp.linear.app/token" + + # Send successful token response + token_response = httpx.Response( + 200, + content=b'{"access_token": "linear_token", "token_type": "Bearer", "expires_in": 3600}', + request=token_request, + ) + + # Final request with auth header + final_request = await auth_flow.asend(token_response) + assert final_request.headers["Authorization"] == "Bearer linear_token" + assert str(final_request.url) == "https://mcp.linear.app/sse" + + # Complete flow + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass + + @pytest.mark.anyio + async def test_legacy_server_with_different_prm_and_root_urls( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """Test PRM fallback with different WWW-Authenticate and root URLs.""" + + async def redirect_handler(url: str) -> None: + pass # pragma: no cover + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "test_state" # pragma: no cover + + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + + provider.context.current_tokens = None + provider.context.token_expiry_time = None + provider._initialized = True + + provider.context.client_info = OAuthClientInformationFull( + client_id="existing_client", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + + test_request = httpx.Request("GET", "https://api.example.com/v1/mcp") + auth_flow = provider.async_auth_flow(test_request) + + await auth_flow.__anext__() + + # 401 with custom WWW-Authenticate PRM URL + response = httpx.Response( + 401, + headers={ + "WWW-Authenticate": 'Bearer resource_metadata="https://custom.prm.com/.well-known/oauth-protected-resource"' + }, + request=test_request, + ) + + # Try custom PRM URL first + prm_request_1 = await auth_flow.asend(response) + assert str(prm_request_1.url) == "https://custom.prm.com/.well-known/oauth-protected-resource" + + # Returns 500 + prm_response_1 = httpx.Response(500, request=prm_request_1) + + # Try path-based fallback + prm_request_2 = await auth_flow.asend(prm_response_1) + assert str(prm_request_2.url) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp" + + # Returns 404 + prm_response_2 = httpx.Response(404, request=prm_request_2) + + # Try root fallback + prm_request_3 = await auth_flow.asend(prm_response_2) + assert str(prm_request_3.url) == "https://api.example.com/.well-known/oauth-protected-resource" + + # Also returns 404 - all PRM URLs failed + prm_response_3 = httpx.Response(404, request=prm_request_3) + + # Should fall back to root OAuth discovery + oauth_metadata_request = await auth_flow.asend(prm_response_3) + assert str(oauth_metadata_request.url) == "https://api.example.com/.well-known/oauth-authorization-server" + + # Complete the flow + oauth_metadata_response = httpx.Response( + 200, + content=( + b'{"issuer": "https://api.example.com", ' + b'"authorization_endpoint": "https://api.example.com/authorize", ' + b'"token_endpoint": "https://api.example.com/token"}' + ), + request=oauth_metadata_request, + ) + + provider._perform_authorization_code_grant = mock.AsyncMock( + return_value=("test_auth_code", "test_code_verifier") + ) + + token_request = await auth_flow.asend(oauth_metadata_response) + assert str(token_request.url) == "https://api.example.com/token" + + token_response = httpx.Response( + 200, + content=b'{"access_token": "test_token", "token_type": "Bearer", "expires_in": 3600}', + request=token_request, + ) + + final_request = await auth_flow.asend(token_response) + assert final_request.headers["Authorization"] == "Bearer test_token" + + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass + + +class TestSEP985Discovery: + """Test SEP-985 protected resource metadata discovery with fallback.""" + + @pytest.mark.anyio + async def test_path_based_fallback_when_no_www_authenticate( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """Test that client falls back to path-based well-known URI when WWW-Authenticate is absent.""" + + async def redirect_handler(url: str) -> None: + pass # pragma: no cover + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "test_state" # pragma: no cover + + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + + # Test with 401 response without WWW-Authenticate header + init_response = httpx.Response( + status_code=401, headers={}, request=httpx.Request("GET", "https://api.example.com/v1/mcp") + ) + + # Build discovery URLs + discovery_urls = build_protected_resource_metadata_discovery_urls( + extract_resource_metadata_from_www_auth(init_response), provider.context.server_url + ) + + # Should have path-based URL first, then root-based URL + assert len(discovery_urls) == 2 + assert discovery_urls[0] == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp" + assert discovery_urls[1] == "https://api.example.com/.well-known/oauth-protected-resource" + + @pytest.mark.anyio + async def test_root_based_fallback_after_path_based_404( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """Test that client falls back to root-based URI when path-based returns 404.""" + + async def redirect_handler(url: str) -> None: + pass # pragma: no cover + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "test_state" # pragma: no cover + + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + + # Ensure no tokens are stored + provider.context.current_tokens = None + provider.context.token_expiry_time = None + provider._initialized = True + + # Mock client info to skip DCR + provider.context.client_info = OAuthClientInformationFull( + client_id="existing_client", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + + # Create a test request + test_request = httpx.Request("GET", "https://api.example.com/v1/mcp") + + # Mock the auth flow + auth_flow = provider.async_auth_flow(test_request) + + # First request should be the original request without auth header + request = await auth_flow.__anext__() + assert "Authorization" not in request.headers + + # Send a 401 response without WWW-Authenticate header + response = httpx.Response(401, headers={}, request=test_request) + + # Next request should be to discover protected resource metadata (path-based) + discovery_request_1 = await auth_flow.asend(response) + assert str(discovery_request_1.url) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp" + assert discovery_request_1.method == "GET" + + # Send 404 response for path-based discovery + discovery_response_1 = httpx.Response(404, request=discovery_request_1) + + # Next request should be to root-based well-known URI + discovery_request_2 = await auth_flow.asend(discovery_response_1) + assert str(discovery_request_2.url) == "https://api.example.com/.well-known/oauth-protected-resource" + assert discovery_request_2.method == "GET" + + # Send successful discovery response + discovery_response_2 = httpx.Response( + 200, + content=( + b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}' + ), + request=discovery_request_2, + ) + + # Mock the rest of the OAuth flow + provider._perform_authorization = mock.AsyncMock(return_value=("test_auth_code", "test_code_verifier")) + + # Next should be OAuth metadata discovery + oauth_metadata_request = await auth_flow.asend(discovery_response_2) + assert oauth_metadata_request.method == "GET" + + # Complete the flow + oauth_metadata_response = httpx.Response( + 200, + content=( + b'{"issuer": "https://auth.example.com", ' + b'"authorization_endpoint": "https://auth.example.com/authorize", ' + b'"token_endpoint": "https://auth.example.com/token"}' + ), + request=oauth_metadata_request, + ) + + token_request = await auth_flow.asend(oauth_metadata_response) + token_response = httpx.Response( + 200, + content=( + b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600, ' + b'"refresh_token": "new_refresh_token"}' + ), + request=token_request, + ) + + final_request = await auth_flow.asend(token_response) + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: # pragma: no cover + pass + + @pytest.mark.anyio + async def test_www_authenticate_takes_priority_over_well_known( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """Test that WWW-Authenticate header resource_metadata takes priority over well-known URIs.""" + + async def redirect_handler(url: str) -> None: + pass # pragma: no cover + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "test_state" # pragma: no cover + + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + + # Test with 401 response with WWW-Authenticate header + init_response = httpx.Response( + status_code=401, + headers={ + "WWW-Authenticate": 'Bearer resource_metadata="https://custom.example.com/.well-known/oauth-protected-resource"' + }, + request=httpx.Request("GET", "https://api.example.com/v1/mcp"), + ) + + # Build discovery URLs + discovery_urls = build_protected_resource_metadata_discovery_urls( + extract_resource_metadata_from_www_auth(init_response), provider.context.server_url + ) + + # Should have WWW-Authenticate URL first, then fallback URLs + assert len(discovery_urls) == 3 + assert discovery_urls[0] == "https://custom.example.com/.well-known/oauth-protected-resource" + assert discovery_urls[1] == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp" + assert discovery_urls[2] == "https://api.example.com/.well-known/oauth-protected-resource" + + +class TestWWWAuthenticate: + """Test WWW-Authenticate header parsing functionality.""" + + @pytest.mark.parametrize( + "www_auth_header,field_name,expected_value", + [ + # Quoted values + ('Bearer scope="read write"', "scope", "read write"), + ( + 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"', + "resource_metadata", + "https://api.example.com/.well-known/oauth-protected-resource", + ), + ('Bearer error="insufficient_scope"', "error", "insufficient_scope"), + # Unquoted values + ("Bearer scope=read", "scope", "read"), + ( + "Bearer resource_metadata=https://api.example.com/.well-known/oauth-protected-resource", + "resource_metadata", + "https://api.example.com/.well-known/oauth-protected-resource", + ), + ("Bearer error=invalid_token", "error", "invalid_token"), + # Multiple parameters with quoted value + ( + 'Bearer realm="api", scope="admin:write resource:read", error="insufficient_scope"', + "scope", + "admin:write resource:read", + ), + ( + 'Bearer realm="api", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource", ' + 'error="insufficient_scope"', + "resource_metadata", + "https://api.example.com/.well-known/oauth-protected-resource", + ), + # Multiple parameters with unquoted value + ('Bearer realm="api", scope=basic', "scope", "basic"), + # Values with special characters + ( + 'Bearer scope="resource:read resource:write user_profile"', + "scope", + "resource:read resource:write user_profile", + ), + ( + 'Bearer resource_metadata="https://api.example.com/auth/metadata?version=1"', + "resource_metadata", + "https://api.example.com/auth/metadata?version=1", + ), + ], + ) + def test_extract_field_from_www_auth_valid_cases( + self, + client_metadata: OAuthClientMetadata, + mock_storage: MockTokenStorage, + www_auth_header: str, + field_name: str, + expected_value: str, + ): + """Test extraction of various fields from valid WWW-Authenticate headers.""" + + init_response = httpx.Response( + status_code=401, + headers={"WWW-Authenticate": www_auth_header}, + request=httpx.Request("GET", "https://api.example.com/test"), + ) + + result = extract_field_from_www_auth(init_response, field_name) + assert result == expected_value + + @pytest.mark.parametrize( + "www_auth_header,field_name,description", + [ + # No header + (None, "scope", "no WWW-Authenticate header"), + # Empty header + ("", "scope", "empty WWW-Authenticate header"), + # Header without requested field + ('Bearer realm="api", error="insufficient_scope"', "scope", "no scope parameter"), + ('Bearer realm="api", scope="read write"', "resource_metadata", "no resource_metadata parameter"), + # Malformed field (empty value) + ("Bearer scope=", "scope", "malformed scope parameter"), + ("Bearer resource_metadata=", "resource_metadata", "malformed resource_metadata parameter"), + ], + ) + def test_extract_field_from_www_auth_invalid_cases( + self, + client_metadata: OAuthClientMetadata, + mock_storage: MockTokenStorage, + www_auth_header: str | None, + field_name: str, + description: str, + ): + """Test extraction returns None for invalid cases.""" + + headers = {"WWW-Authenticate": www_auth_header} if www_auth_header is not None else {} + init_response = httpx.Response( + status_code=401, headers=headers, request=httpx.Request("GET", "https://api.example.com/test") + ) + + result = extract_field_from_www_auth(init_response, field_name) + assert result is None, f"Should return None for {description}" + + +class TestCIMD: + """Test Client ID Metadata Document (CIMD) support.""" + + @pytest.mark.parametrize( + "url,expected", + [ + # Valid CIMD URLs + ("https://example.com/client", True), + ("https://example.com/client-metadata.json", True), + ("https://example.com/path/to/client", True), + ("https://example.com:8443/client", True), + # Invalid URLs - HTTP (not HTTPS) + ("http://example.com/client", False), + # Invalid URLs - root path + ("https://example.com", False), + ("https://example.com/", False), + # Invalid URLs - None or empty + (None, False), + ("", False), + # Invalid URLs - malformed (triggers urlparse exception) + ("http://[::1/foo/", False), + ], + ) + def test_is_valid_client_metadata_url(self, url: str | None, expected: bool): + """Test CIMD URL validation.""" + assert is_valid_client_metadata_url(url) == expected + + def test_should_use_client_metadata_url_when_server_supports(self): + """Test that CIMD is used when server supports it and URL is provided.""" + oauth_metadata = OAuthMetadata( + issuer=AnyHttpUrl("https://auth.example.com"), + authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://auth.example.com/token"), + client_id_metadata_document_supported=True, + ) + assert should_use_client_metadata_url(oauth_metadata, "https://example.com/client") is True + + def test_should_not_use_client_metadata_url_when_server_does_not_support(self): + """Test that CIMD is not used when server doesn't support it.""" + oauth_metadata = OAuthMetadata( + issuer=AnyHttpUrl("https://auth.example.com"), + authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://auth.example.com/token"), + client_id_metadata_document_supported=False, + ) + assert should_use_client_metadata_url(oauth_metadata, "https://example.com/client") is False + + def test_should_not_use_client_metadata_url_when_not_provided(self): + """Test that CIMD is not used when no URL is provided.""" + oauth_metadata = OAuthMetadata( + issuer=AnyHttpUrl("https://auth.example.com"), + authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://auth.example.com/token"), + client_id_metadata_document_supported=True, + ) + assert should_use_client_metadata_url(oauth_metadata, None) is False + + def test_should_not_use_client_metadata_url_when_no_metadata(self): + """Test that CIMD is not used when OAuth metadata is None.""" + assert should_use_client_metadata_url(None, "https://example.com/client") is False + + def test_create_client_info_from_metadata_url(self): + """Test creating client info from CIMD URL.""" + client_info = create_client_info_from_metadata_url( + "https://example.com/client", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + assert client_info.client_id == "https://example.com/client" + assert client_info.token_endpoint_auth_method == "none" + assert client_info.redirect_uris == [AnyUrl("http://localhost:3030/callback")] + assert client_info.client_secret is None + + def test_oauth_provider_with_valid_client_metadata_url( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """Test OAuthClientProvider initialization with valid client_metadata_url.""" + + async def redirect_handler(url: str) -> None: + pass # pragma: no cover + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "test_state" # pragma: no cover + + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + client_metadata_url="https://example.com/client", + ) + assert provider.context.client_metadata_url == "https://example.com/client" + + def test_oauth_provider_with_invalid_client_metadata_url_raises_error( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """Test OAuthClientProvider raises error for invalid client_metadata_url.""" + + async def redirect_handler(url: str) -> None: + pass # pragma: no cover + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "test_state" # pragma: no cover + + with pytest.raises(ValueError) as exc_info: + OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + client_metadata_url="http://example.com/client", # HTTP instead of HTTPS + ) + assert "HTTPS URL with a non-root pathname" in str(exc_info.value) + + @pytest.mark.anyio + async def test_auth_flow_uses_cimd_when_server_supports( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """Test that auth flow uses CIMD URL as client_id when server supports it.""" + + async def redirect_handler(url: str) -> None: + pass # pragma: no cover + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "test_state" # pragma: no cover + + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + client_metadata_url="https://example.com/client", + ) + + provider.context.current_tokens = None + provider.context.token_expiry_time = None + provider._initialized = True + + test_request = httpx.Request("GET", "https://api.example.com/v1/mcp") + auth_flow = provider.async_auth_flow(test_request) + + # First request + request = await auth_flow.__anext__() + assert "Authorization" not in request.headers + + # Send 401 response + response = httpx.Response(401, headers={}, request=test_request) + + # PRM discovery + prm_request = await auth_flow.asend(response) + prm_response = httpx.Response( + 200, + content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}', + request=prm_request, + ) + + # OAuth metadata discovery + oauth_request = await auth_flow.asend(prm_response) + oauth_response = httpx.Response( + 200, + content=( + b'{"issuer": "https://auth.example.com", ' + b'"authorization_endpoint": "https://auth.example.com/authorize", ' + b'"token_endpoint": "https://auth.example.com/token", ' + b'"client_id_metadata_document_supported": true}' + ), + request=oauth_request, + ) + + # Mock authorization + provider._perform_authorization_code_grant = mock.AsyncMock( + return_value=("test_auth_code", "test_code_verifier") + ) + + # Should skip DCR and go directly to token exchange + token_request = await auth_flow.asend(oauth_response) + assert token_request.method == "POST" + assert str(token_request.url) == "https://auth.example.com/token" + + # Verify client_id is the CIMD URL + content = token_request.content.decode() + assert "client_id=https%3A%2F%2Fexample.com%2Fclient" in content + + # Verify client info was set correctly + assert provider.context.client_info is not None + assert provider.context.client_info.client_id == "https://example.com/client" + assert provider.context.client_info.token_endpoint_auth_method == "none" + + # Complete the flow + token_response = httpx.Response( + 200, + content=b'{"access_token": "test_token", "token_type": "Bearer", "expires_in": 3600}', + request=token_request, + ) + + final_request = await auth_flow.asend(token_response) + assert final_request.headers["Authorization"] == "Bearer test_token" + + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass + + @pytest.mark.anyio + async def test_auth_flow_falls_back_to_dcr_when_no_cimd_support( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """Test that auth flow falls back to DCR when server doesn't support CIMD.""" + + async def redirect_handler(url: str) -> None: + pass # pragma: no cover + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "test_state" # pragma: no cover + + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + client_metadata_url="https://example.com/client", + ) + + provider.context.current_tokens = None + provider.context.token_expiry_time = None + provider._initialized = True + + test_request = httpx.Request("GET", "https://api.example.com/v1/mcp") + auth_flow = provider.async_auth_flow(test_request) + + # First request + await auth_flow.__anext__() + + # Send 401 response + response = httpx.Response(401, headers={}, request=test_request) + + # PRM discovery + prm_request = await auth_flow.asend(response) + prm_response = httpx.Response( + 200, + content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}', + request=prm_request, + ) + + # OAuth metadata discovery - server does NOT support CIMD + oauth_request = await auth_flow.asend(prm_response) + oauth_response = httpx.Response( + 200, + content=( + b'{"issuer": "https://auth.example.com", ' + b'"authorization_endpoint": "https://auth.example.com/authorize", ' + b'"token_endpoint": "https://auth.example.com/token", ' + b'"registration_endpoint": "https://auth.example.com/register"}' + ), + request=oauth_request, + ) + + # Should proceed to DCR instead of skipping it + registration_request = await auth_flow.asend(oauth_response) + assert registration_request.method == "POST" + assert str(registration_request.url) == "https://auth.example.com/register" + + # Complete the flow to avoid generator cleanup issues + registration_response = httpx.Response( + 201, + content=b'{"client_id": "dcr_client_id", "redirect_uris": ["http://localhost:3030/callback"]}', + request=registration_request, + ) + + # Mock authorization + provider._perform_authorization_code_grant = mock.AsyncMock( + return_value=("test_auth_code", "test_code_verifier") + ) + + token_request = await auth_flow.asend(registration_response) + token_response = httpx.Response( + 200, + content=b'{"access_token": "test_token", "token_type": "Bearer", "expires_in": 3600}', + request=token_request, + ) + + final_request = await auth_flow.asend(token_response) + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass diff --git a/tests/client/test_config.py b/tests/client/test_config.py index 97030e0691..d1a0576ff3 100644 --- a/tests/client/test_config.py +++ b/tests/client/test_config.py @@ -44,7 +44,32 @@ def test_command_execution(mock_config_path: Path): test_args = [command] + args + ["--help"] - result = subprocess.run(test_args, capture_output=True, text=True, timeout=5) + result = subprocess.run(test_args, capture_output=True, text=True, timeout=20, check=False) assert result.returncode == 0 assert "usage" in result.stdout.lower() + + +def test_absolute_uv_path(mock_config_path: Path): + """Test that the absolute path to uv is used when available.""" + # Mock the shutil.which function to return a fake path + mock_uv_path = "/usr/local/bin/uv" + + with patch("mcp.cli.claude.get_uv_path", return_value=mock_uv_path): + # Setup + server_name = "test_server" + file_spec = "test_server.py:app" + + # Update config + success = update_claude_config(file_spec=file_spec, server_name=server_name) + assert success + + # Read the generated config + config_file = mock_config_path / "claude_desktop_config.json" + config = json.loads(config_file.read_text()) + + # Verify the command is the absolute path + server_config = config["mcpServers"][server_name] + command = server_config["command"] + + assert command == mock_uv_path diff --git a/tests/client/test_http_unicode.py b/tests/client/test_http_unicode.py new file mode 100644 index 0000000000..ec38f35838 --- /dev/null +++ b/tests/client/test_http_unicode.py @@ -0,0 +1,233 @@ +""" +Tests for Unicode handling in streamable HTTP transport. + +Verifies that Unicode text is correctly transmitted and received in both directions +(server→client and client→server) using the streamable HTTP transport. +""" + +import multiprocessing +import socket +from collections.abc import Generator + +import pytest + +from mcp.client.session import ClientSession +from mcp.client.streamable_http import streamable_http_client +from tests.test_helpers import wait_for_server + +# Test constants with various Unicode characters +UNICODE_TEST_STRINGS = { + "cyrillic": "Слой хранилища, где располагаются", + "cyrillic_short": "Привет мир", + "chinese": "你好世界 - 这是一个测试", + "japanese": "こんにちは世界 - これはテストです", + "korean": "안녕하세요 세계 - 이것은 테스트입니다", + "arabic": "مرحبا بالعالم - هذا اختبار", + "hebrew": "שלום עולם - זה מבחן", + "greek": "Γεια σου κόσμε - αυτό είναι δοκιμή", + "emoji": "Hello 👋 World 🌍 - Testing 🧪 Unicode ✨", + "math": "∑ ∫ √ ∞ ≠ ≤ ≥ ∈ ∉ ⊆ ⊇", + "accented": "Café, naïve, résumé, piñata, Zürich", + "mixed": "Hello世界🌍Привет안녕مرحباשלום", + "special": "Line\nbreak\ttab\r\nCRLF", + "quotes": '«French» „German" "English" 「Japanese」', + "currency": "€100 £50 ¥1000 ₹500 ₽200 ¢99", +} + + +def run_unicode_server(port: int) -> None: # pragma: no cover + """Run the Unicode test server in a separate process.""" + # Import inside the function since this runs in a separate process + from collections.abc import AsyncGenerator + from contextlib import asynccontextmanager + from typing import Any + + import uvicorn + from starlette.applications import Starlette + from starlette.routing import Mount + + import mcp.types as types + from mcp.server import Server + from mcp.server.streamable_http_manager import StreamableHTTPSessionManager + from mcp.types import TextContent, Tool + + # Need to recreate the server setup in this process + server = Server(name="unicode_test_server") + + @server.list_tools() + async def list_tools() -> list[Tool]: + """List tools with Unicode descriptions.""" + return [ + Tool( + name="echo_unicode", + description="🔤 Echo Unicode text - Hello 👋 World 🌍 - Testing 🧪 Unicode ✨", + inputSchema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to echo back"}, + }, + "required": ["text"], + }, + ), + ] + + @server.call_tool() + async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[TextContent]: + """Handle tool calls with Unicode content.""" + if name == "echo_unicode": + text = arguments.get("text", "") if arguments else "" + return [ + TextContent( + type="text", + text=f"Echo: {text}", + ) + ] + else: + raise ValueError(f"Unknown tool: {name}") + + @server.list_prompts() + async def list_prompts() -> list[types.Prompt]: + """List prompts with Unicode names and descriptions.""" + return [ + types.Prompt( + name="unicode_prompt", + description="Unicode prompt - Слой хранилища, где располагаются", + arguments=[], + ) + ] + + @server.get_prompt() + async def get_prompt(name: str, arguments: dict[str, Any] | None) -> types.GetPromptResult: + """Get a prompt with Unicode content.""" + if name == "unicode_prompt": + return types.GetPromptResult( + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent( + type="text", + text="Hello世界🌍Привет안녕مرحباשלום", + ), + ) + ] + ) + raise ValueError(f"Unknown prompt: {name}") + + # Create the session manager + session_manager = StreamableHTTPSessionManager( + app=server, + json_response=False, # Use SSE for testing + ) + + @asynccontextmanager + async def lifespan(app: Starlette) -> AsyncGenerator[None, None]: + async with session_manager.run(): + yield + + # Create an ASGI application + app = Starlette( + debug=True, + routes=[ + Mount("/mcp", app=session_manager.handle_request), + ], + lifespan=lifespan, + ) + + # Run the server + config = uvicorn.Config( + app=app, + host="127.0.0.1", + port=port, + log_level="error", + ) + uvicorn_server = uvicorn.Server(config) + uvicorn_server.run() + + +@pytest.fixture +def unicode_server_port() -> int: + """Find an available port for the Unicode test server.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def running_unicode_server(unicode_server_port: int) -> Generator[str, None, None]: + """Start a Unicode test server in a separate process.""" + proc = multiprocessing.Process(target=run_unicode_server, kwargs={"port": unicode_server_port}, daemon=True) + proc.start() + + # Wait for server to be ready + wait_for_server(unicode_server_port) + + try: + yield f"http://127.0.0.1:{unicode_server_port}" + finally: + # Clean up - try graceful termination first + proc.terminate() + proc.join(timeout=2) + if proc.is_alive(): # pragma: no cover + proc.kill() + proc.join(timeout=1) + + +@pytest.mark.anyio +async def test_streamable_http_client_unicode_tool_call(running_unicode_server: str) -> None: + """Test that Unicode text is correctly handled in tool calls via streamable HTTP.""" + base_url = running_unicode_server + endpoint_url = f"{base_url}/mcp" + + async with streamable_http_client(endpoint_url) as (read_stream, write_stream, _get_session_id): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + + # Test 1: List tools (server→client Unicode in descriptions) + tools = await session.list_tools() + assert len(tools.tools) == 1 + + # Check Unicode in tool descriptions + echo_tool = tools.tools[0] + assert echo_tool.name == "echo_unicode" + assert echo_tool.description is not None + assert "🔤" in echo_tool.description + assert "👋" in echo_tool.description + + # Test 2: Send Unicode text in tool call (client→server→client) + for test_name, test_string in UNICODE_TEST_STRINGS.items(): + result = await session.call_tool("echo_unicode", arguments={"text": test_string}) + + # Verify server correctly received and echoed back Unicode + assert len(result.content) == 1 + content = result.content[0] + assert content.type == "text" + assert f"Echo: {test_string}" == content.text, f"Failed for {test_name}" + + +@pytest.mark.anyio +async def test_streamable_http_client_unicode_prompts(running_unicode_server: str) -> None: + """Test that Unicode text is correctly handled in prompts via streamable HTTP.""" + base_url = running_unicode_server + endpoint_url = f"{base_url}/mcp" + + async with streamable_http_client(endpoint_url) as (read_stream, write_stream, _get_session_id): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + + # Test 1: List prompts (server→client Unicode in descriptions) + prompts = await session.list_prompts() + assert len(prompts.prompts) == 1 + + prompt = prompts.prompts[0] + assert prompt.name == "unicode_prompt" + assert prompt.description is not None + assert "Слой хранилища, где располагаются" in prompt.description + + # Test 2: Get prompt with Unicode content (server→client) + result = await session.get_prompt("unicode_prompt", arguments={}) + assert len(result.messages) == 1 + + message = result.messages[0] + assert message.role == "user" + assert message.content.type == "text" + assert message.content.text == "Hello世界🌍Привет안녕مرحباשלום" diff --git a/tests/client/test_list_methods_cursor.py b/tests/client/test_list_methods_cursor.py new file mode 100644 index 0000000000..94a72c34e2 --- /dev/null +++ b/tests/client/test_list_methods_cursor.py @@ -0,0 +1,228 @@ +from collections.abc import Callable + +import pytest + +import mcp.types as types +from mcp.server import Server +from mcp.server.fastmcp import FastMCP +from mcp.shared.memory import create_connected_server_and_client_session as create_session +from mcp.types import ListToolsRequest, ListToolsResult + +from .conftest import StreamSpyCollection + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +async def full_featured_server(): + """Create a server with tools, resources, prompts, and templates.""" + server = FastMCP("test") + + @server.tool(name="test_tool_1") + async def test_tool_1() -> str: # pragma: no cover + """First test tool""" + return "Result 1" + + @server.tool(name="test_tool_2") + async def test_tool_2() -> str: # pragma: no cover + """Second test tool""" + return "Result 2" + + @server.resource("resource://test/data") + async def test_resource() -> str: # pragma: no cover + """Test resource""" + return "Test data" + + @server.prompt() + async def test_prompt(name: str) -> str: # pragma: no cover + """Test prompt""" + return f"Hello, {name}!" + + @server.resource("resource://test/{name}") + async def test_template(name: str) -> str: # pragma: no cover + """Test resource template""" + return f"Data for {name}" + + return server + + +@pytest.mark.parametrize( + "method_name,request_method", + [ + ("list_tools", "tools/list"), + ("list_resources", "resources/list"), + ("list_prompts", "prompts/list"), + ("list_resource_templates", "resources/templates/list"), + ], +) +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_list_methods_cursor_parameter( + stream_spy: Callable[[], StreamSpyCollection], + full_featured_server: FastMCP, + method_name: str, + request_method: str, +): + """Test that the cursor parameter is accepted and correctly passed to the server. + + Covers: list_tools, list_resources, list_prompts, list_resource_templates + + See: https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/pagination#request-format + """ + async with create_session(full_featured_server._mcp_server) as client_session: + spies = stream_spy() + + # Test without cursor parameter (omitted) + method = getattr(client_session, method_name) + _ = await method() + requests = spies.get_client_requests(method=request_method) + assert len(requests) == 1 + assert requests[0].params is None + + spies.clear() + + # Test with cursor=None + _ = await method(cursor=None) + requests = spies.get_client_requests(method=request_method) + assert len(requests) == 1 + assert requests[0].params is None + + spies.clear() + + # Test with cursor as string + _ = await method(cursor="some_cursor_value") + requests = spies.get_client_requests(method=request_method) + assert len(requests) == 1 + assert requests[0].params is not None + assert requests[0].params["cursor"] == "some_cursor_value" + + spies.clear() + + # Test with empty string cursor + _ = await method(cursor="") + requests = spies.get_client_requests(method=request_method) + assert len(requests) == 1 + assert requests[0].params is not None + assert requests[0].params["cursor"] == "" + + +@pytest.mark.parametrize( + "method_name,request_method", + [ + ("list_tools", "tools/list"), + ("list_resources", "resources/list"), + ("list_prompts", "prompts/list"), + ("list_resource_templates", "resources/templates/list"), + ], +) +async def test_list_methods_params_parameter( + stream_spy: Callable[[], StreamSpyCollection], + full_featured_server: FastMCP, + method_name: str, + request_method: str, +): + """Test that the params parameter works correctly for list methods. + + Covers: list_tools, list_resources, list_prompts, list_resource_templates + + This tests the new params parameter API (non-deprecated) to ensure + it correctly handles all parameter combinations. + """ + async with create_session(full_featured_server._mcp_server) as client_session: + spies = stream_spy() + method = getattr(client_session, method_name) + + # Test without params parameter (omitted) + _ = await method() + requests = spies.get_client_requests(method=request_method) + assert len(requests) == 1 + assert requests[0].params is None + + spies.clear() + + # Test with params=None + _ = await method(params=None) + requests = spies.get_client_requests(method=request_method) + assert len(requests) == 1 + assert requests[0].params is None + + spies.clear() + + # Test with empty params (for strict servers) + _ = await method(params=types.PaginatedRequestParams()) + requests = spies.get_client_requests(method=request_method) + assert len(requests) == 1 + assert requests[0].params is not None + assert requests[0].params.get("cursor") is None + + spies.clear() + + # Test with params containing cursor + _ = await method(params=types.PaginatedRequestParams(cursor="some_cursor_value")) + requests = spies.get_client_requests(method=request_method) + assert len(requests) == 1 + assert requests[0].params is not None + assert requests[0].params["cursor"] == "some_cursor_value" + + +@pytest.mark.parametrize( + "method_name", + [ + "list_tools", + "list_resources", + "list_prompts", + "list_resource_templates", + ], +) +async def test_list_methods_raises_error_when_both_cursor_and_params_provided( + full_featured_server: FastMCP, + method_name: str, +): + """Test that providing both cursor and params raises ValueError. + + Covers: list_tools, list_resources, list_prompts, list_resource_templates + + When both cursor and params are provided, a ValueError should be raised + to prevent ambiguity. + """ + async with create_session(full_featured_server._mcp_server) as client_session: + method = getattr(client_session, method_name) + + # Call with both cursor and params - should raise ValueError + with pytest.raises(ValueError, match="Cannot specify both cursor and params"): + await method( + cursor="old_cursor", + params=types.PaginatedRequestParams(cursor="new_cursor"), + ) + + +async def test_list_tools_with_strict_server_validation(): + """Test that list_tools works with strict servers require a params field, + even if it is empty. + + Some MCP servers may implement strict JSON-RPC validation that requires + the params field to always be present in requests, even if empty {}. + + This test ensures such servers are supported by the client SDK for list_resources + requests without a cursor. + """ + + server = Server("strict_server") + + @server.list_tools() + async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult: # pragma: no cover + """Strict handler that validates params field exists""" + + # Simulate strict server validation + if request.params is None: + raise ValueError( + "Strict server validation failed: params field must be present. " + "Expected params: {} for requests without cursor." + ) + + # Return empty tools list + return ListToolsResult(tools=[]) + + async with create_session(server) as client_session: + # Use params to explicitly send params: {} for strict server compatibility + result = await client_session.list_tools(params=types.PaginatedRequestParams()) + assert result is not None diff --git a/tests/client/test_list_roots_callback.py b/tests/client/test_list_roots_callback.py index f5b5982183..0da0fff07a 100644 --- a/tests/client/test_list_roots_callback.py +++ b/tests/client/test_list_roots_callback.py @@ -3,6 +3,7 @@ from mcp.client.session import ClientSession from mcp.server.fastmcp.server import Context +from mcp.server.session import ServerSession from mcp.shared.context import RequestContext from mcp.shared.memory import ( create_connected_server_and_client_session as create_session, @@ -35,19 +36,15 @@ async def list_roots_callback( return callback_return @server.tool("test_list_roots") - async def test_list_roots(context: Context, message: str): # type: ignore[reportUnknownMemberType] + async def test_list_roots(context: Context[ServerSession, None], message: str): roots = await context.session.list_roots() assert roots == callback_return return True # Test with list_roots callback - async with create_session( - server._mcp_server, list_roots_callback=list_roots_callback - ) as client_session: + async with create_session(server._mcp_server, list_roots_callback=list_roots_callback) as client_session: # Make a request to trigger sampling callback - result = await client_session.call_tool( - "test_list_roots", {"message": "test message"} - ) + result = await client_session.call_tool("test_list_roots", {"message": "test message"}) assert result.isError is False assert isinstance(result.content[0], TextContent) assert result.content[0].text == "true" @@ -55,12 +52,7 @@ async def test_list_roots(context: Context, message: str): # type: ignore[repor # Test without list_roots callback async with create_session(server._mcp_server) as client_session: # Make a request to trigger sampling callback - result = await client_session.call_tool( - "test_list_roots", {"message": "test message"} - ) + result = await client_session.call_tool("test_list_roots", {"message": "test message"}) assert result.isError is True assert isinstance(result.content[0], TextContent) - assert ( - result.content[0].text - == "Error executing tool test_list_roots: List roots not supported" - ) + assert result.content[0].text == "Error executing tool test_list_roots: List roots not supported" diff --git a/tests/client/test_logging_callback.py b/tests/client/test_logging_callback.py index 797f817e1a..de058eb061 100644 --- a/tests/client/test_logging_callback.py +++ b/tests/client/test_logging_callback.py @@ -1,4 +1,4 @@ -from typing import Literal +from typing import Any, Literal import pytest @@ -47,13 +47,28 @@ async def test_tool_with_log( ) return True + @server.tool("test_tool_with_log_extra") + async def test_tool_with_log_extra( + message: str, + level: Literal["debug", "info", "warning", "error"], + logger: str, + extra_string: str, + extra_dict: dict[str, Any], + ) -> bool: + """Send a log notification to the client with extra fields.""" + await server.get_context().log( + level=level, + message=message, + logger_name=logger, + extra={"extra_string": extra_string, "extra_dict": extra_dict}, + ) + return True + # Create a message handler to catch exceptions async def message_handler( - message: RequestResponder[types.ServerRequest, types.ClientResult] - | types.ServerNotification - | Exception, + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, ) -> None: - if isinstance(message, Exception): + if isinstance(message, Exception): # pragma: no cover raise message async with create_session( @@ -76,8 +91,30 @@ async def message_handler( "logger": "test_logger", }, ) - assert log_result.isError is False - assert len(logging_collector.log_messages) == 1 - assert logging_collector.log_messages[0] == LoggingMessageNotificationParams( - level="info", logger="test_logger", data="Test log message" + log_result_with_extra = await client_session.call_tool( + "test_tool_with_log_extra", + { + "message": "Test log message", + "level": "info", + "logger": "test_logger", + "extra_string": "example", + "extra_dict": {"a": 1, "b": 2, "c": 3}, + }, ) + assert log_result.isError is False + assert log_result_with_extra.isError is False + assert len(logging_collector.log_messages) == 2 + # Create meta object with related_request_id added dynamically + log = logging_collector.log_messages[0] + assert log.level == "info" + assert log.logger == "test_logger" + assert log.data == "Test log message" + + log_with_extra = logging_collector.log_messages[1] + assert log_with_extra.level == "info" + assert log_with_extra.logger == "test_logger" + assert log_with_extra.data == { + "message": "Test log message", + "extra_string": "example", + "extra_dict": {"a": 1, "b": 2, "c": 3}, + } diff --git a/tests/client/test_notification_response.py b/tests/client/test_notification_response.py new file mode 100644 index 0000000000..7500abee73 --- /dev/null +++ b/tests/client/test_notification_response.py @@ -0,0 +1,145 @@ +""" +Tests for StreamableHTTP client transport with non-SDK servers. + +These tests verify client behavior when interacting with servers +that don't follow SDK conventions. +""" + +import json +import multiprocessing +import socket +from collections.abc import Generator + +import pytest +import uvicorn +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route + +from mcp import ClientSession, types +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.session import RequestResponder +from mcp.types import ClientNotification, RootsListChangedNotification +from tests.test_helpers import wait_for_server + + +def create_non_sdk_server_app() -> Starlette: # pragma: no cover + """Create a minimal server that doesn't follow SDK conventions.""" + + async def handle_mcp_request(request: Request) -> Response: + """Handle MCP requests with non-standard responses.""" + try: + body = await request.body() + data = json.loads(body) + + # Handle initialize request normally + if data.get("method") == "initialize": + response_data = { + "jsonrpc": "2.0", + "id": data["id"], + "result": { + "serverInfo": {"name": "test-non-sdk-server", "version": "1.0.0"}, + "protocolVersion": "2024-11-05", + "capabilities": {}, + }, + } + return JSONResponse(response_data) + + # For notifications, return 204 No Content (non-SDK behavior) + if "id" not in data: + return Response(status_code=204, headers={"Content-Type": "application/json"}) + + # Default response for other requests + return JSONResponse( + {"jsonrpc": "2.0", "id": data.get("id"), "error": {"code": -32601, "message": "Method not found"}} + ) + + except Exception as e: + return JSONResponse({"error": f"Server error: {str(e)}"}, status_code=500) + + app = Starlette( + debug=True, + routes=[ + Route("/mcp", handle_mcp_request, methods=["POST"]), + ], + ) + return app + + +def run_non_sdk_server(port: int) -> None: # pragma: no cover + """Run the non-SDK server in a separate process.""" + app = create_non_sdk_server_app() + config = uvicorn.Config( + app=app, + host="127.0.0.1", + port=port, + log_level="error", # Reduce noise in tests + ) + server = uvicorn.Server(config=config) + server.run() + + +@pytest.fixture +def non_sdk_server_port() -> int: + """Get an available port for the test server.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def non_sdk_server(non_sdk_server_port: int) -> Generator[None, None, None]: + """Start a non-SDK server for testing.""" + proc = multiprocessing.Process(target=run_non_sdk_server, kwargs={"port": non_sdk_server_port}, daemon=True) + proc.start() + + # Wait for server to be ready + try: # pragma: no cover + wait_for_server(non_sdk_server_port, timeout=10.0) + except TimeoutError: # pragma: no cover + proc.kill() + proc.join(timeout=2) + pytest.fail("Server failed to start within 10 seconds") + + yield + + proc.kill() + proc.join(timeout=2) + + +@pytest.mark.anyio +async def test_non_compliant_notification_response(non_sdk_server: None, non_sdk_server_port: int) -> None: + """ + This test verifies that the client ignores unexpected responses to notifications: the spec states they should + either be 202 + no response body, or 4xx + optional error body + (https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server), + but some servers wrongly return other 2xx codes (e.g. 204). For now we simply ignore unexpected responses + (aligning behaviour w/ the TS SDK). + """ + server_url = f"http://127.0.0.1:{non_sdk_server_port}/mcp" + returned_exception = None + + async def message_handler( # pragma: no cover + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ): + nonlocal returned_exception + if isinstance(message, Exception): + returned_exception = message + + async with streamable_http_client(server_url) as (read_stream, write_stream, _): + async with ClientSession( + read_stream, + write_stream, + message_handler=message_handler, + ) as session: + # Initialize should work normally + await session.initialize() + + # The test server returns a 204 instead of the expected 202 + await session.send_notification( + ClientNotification(RootsListChangedNotification(method="notifications/roots/list_changed")) + ) + + if returned_exception: # pragma: no cover + pytest.fail(f"Server encountered an exception: {returned_exception}") diff --git a/tests/client/test_output_schema_validation.py b/tests/client/test_output_schema_validation.py new file mode 100644 index 0000000000..e4a06b7f82 --- /dev/null +++ b/tests/client/test_output_schema_validation.py @@ -0,0 +1,217 @@ +import logging +from contextlib import contextmanager +from typing import Any +from unittest.mock import patch + +import pytest + +from mcp.server.lowlevel import Server +from mcp.shared.memory import ( + create_connected_server_and_client_session as client_session, +) +from mcp.types import Tool + + +@contextmanager +def bypass_server_output_validation(): + """ + Context manager that bypasses server-side output validation. + This simulates a malicious or non-compliant server that doesn't validate + its outputs, allowing us to test client-side validation. + """ + import jsonschema + + # Save the original validate function + original_validate = jsonschema.validate + + # Create a mock that tracks which module is calling it + def selective_mock(instance: Any = None, schema: Any = None, *args: Any, **kwargs: Any) -> None: + import inspect + + # Check the call stack to see where this is being called from + for frame_info in inspect.stack(): + # If called from the server module, skip validation + # TODO: fix this as it's a rather gross workaround and will eventually break + # Normalize path separators for cross-platform compatibility + normalized_path = frame_info.filename.replace("\\", "/") + if "mcp/server/lowlevel/server.py" in normalized_path: + return None + # Otherwise, use the real validation (for client-side) + return original_validate(instance=instance, schema=schema, *args, **kwargs) + + with patch("jsonschema.validate", selective_mock): + yield + + +class TestClientOutputSchemaValidation: + """Test client-side validation of structured output from tools""" + + @pytest.mark.anyio + async def test_tool_structured_output_client_side_validation_basemodel(self): + """Test that client validates structured content against schema for BaseModel outputs""" + # Create a malicious low-level server that returns invalid structured content + server = Server("test-server") + + # Define the expected schema for our tool + output_schema = { + "type": "object", + "properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}}, + "required": ["name", "age"], + "title": "UserOutput", + } + + @server.list_tools() + async def list_tools(): + return [ + Tool( + name="get_user", + description="Get user data", + inputSchema={"type": "object"}, + outputSchema=output_schema, + ) + ] + + @server.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]): + # Return invalid structured content - age is string instead of integer + # The low-level server will wrap this in CallToolResult + return {"name": "John", "age": "invalid"} # Invalid: age should be int + + # Test that client validates the structured content + with bypass_server_output_validation(): + async with client_session(server) as client: + # The client validates structured content and should raise an error + with pytest.raises(RuntimeError) as exc_info: + await client.call_tool("get_user", {}) + # Verify it's a validation error + assert "Invalid structured content returned by tool get_user" in str(exc_info.value) + + @pytest.mark.anyio + async def test_tool_structured_output_client_side_validation_primitive(self): + """Test that client validates structured content for primitive outputs""" + server = Server("test-server") + + # Primitive types are wrapped in {"result": value} + output_schema = { + "type": "object", + "properties": {"result": {"type": "integer", "title": "Result"}}, + "required": ["result"], + "title": "calculate_Output", + } + + @server.list_tools() + async def list_tools(): + return [ + Tool( + name="calculate", + description="Calculate something", + inputSchema={"type": "object"}, + outputSchema=output_schema, + ) + ] + + @server.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]): + # Return invalid structured content - result is string instead of integer + return {"result": "not_a_number"} # Invalid: should be int + + with bypass_server_output_validation(): + async with client_session(server) as client: + # The client validates structured content and should raise an error + with pytest.raises(RuntimeError) as exc_info: + await client.call_tool("calculate", {}) + assert "Invalid structured content returned by tool calculate" in str(exc_info.value) + + @pytest.mark.anyio + async def test_tool_structured_output_client_side_validation_dict_typed(self): + """Test that client validates dict[str, T] structured content""" + server = Server("test-server") + + # dict[str, int] schema + output_schema = {"type": "object", "additionalProperties": {"type": "integer"}, "title": "get_scores_Output"} + + @server.list_tools() + async def list_tools(): + return [ + Tool( + name="get_scores", + description="Get scores", + inputSchema={"type": "object"}, + outputSchema=output_schema, + ) + ] + + @server.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]): + # Return invalid structured content - values should be integers + return {"alice": "100", "bob": "85"} # Invalid: values should be int + + with bypass_server_output_validation(): + async with client_session(server) as client: + # The client validates structured content and should raise an error + with pytest.raises(RuntimeError) as exc_info: + await client.call_tool("get_scores", {}) + assert "Invalid structured content returned by tool get_scores" in str(exc_info.value) + + @pytest.mark.anyio + async def test_tool_structured_output_client_side_validation_missing_required(self): + """Test that client validates missing required fields""" + server = Server("test-server") + + output_schema = { + "type": "object", + "properties": {"name": {"type": "string"}, "age": {"type": "integer"}, "email": {"type": "string"}}, + "required": ["name", "age", "email"], # All fields required + "title": "PersonOutput", + } + + @server.list_tools() + async def list_tools(): + return [ + Tool( + name="get_person", + description="Get person data", + inputSchema={"type": "object"}, + outputSchema=output_schema, + ) + ] + + @server.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]): + # Return structured content missing required field 'email' + return {"name": "John", "age": 30} # Missing required 'email' + + with bypass_server_output_validation(): + async with client_session(server) as client: + # The client validates structured content and should raise an error + with pytest.raises(RuntimeError) as exc_info: + await client.call_tool("get_person", {}) + assert "Invalid structured content returned by tool get_person" in str(exc_info.value) + + @pytest.mark.anyio + async def test_tool_not_listed_warning(self, caplog: pytest.LogCaptureFixture): + """Test that client logs warning when tool is not in list_tools but has outputSchema""" + server = Server("test-server") + + @server.list_tools() + async def list_tools() -> list[Tool]: + # Return empty list - tool is not listed + return [] + + @server.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + # Server still responds to the tool call with structured content + return {"result": 42} + + # Set logging level to capture warnings + caplog.set_level(logging.WARNING) + + with bypass_server_output_validation(): + async with client_session(server) as client: + # Call a tool that wasn't listed + result = await client.call_tool("mystery_tool", {}) + assert result.structuredContent == {"result": 42} + assert result.isError is False + + # Check that warning was logged + assert "Tool mystery_tool not listed" in caplog.text diff --git a/tests/client/test_resource_cleanup.py b/tests/client/test_resource_cleanup.py new file mode 100644 index 0000000000..cc6c5059fd --- /dev/null +++ b/tests/client/test_resource_cleanup.py @@ -0,0 +1,63 @@ +from typing import Any +from unittest.mock import patch + +import anyio +import pytest + +from mcp.shared.message import SessionMessage +from mcp.shared.session import BaseSession, RequestId, SendResultT +from mcp.types import ClientNotification, ClientRequest, ClientResult, EmptyResult, ErrorData, PingRequest + + +@pytest.mark.anyio +async def test_send_request_stream_cleanup(): + """ + Test that send_request properly cleans up streams when an exception occurs. + + This test mocks out most of the session functionality to focus on stream cleanup. + """ + + # Create a mock session with the minimal required functionality + class TestSession(BaseSession[ClientRequest, ClientNotification, ClientResult, Any, Any]): + async def _send_response( + self, request_id: RequestId, response: SendResultT | ErrorData + ) -> None: # pragma: no cover + pass + + # Create streams + write_stream_send, write_stream_receive = anyio.create_memory_object_stream[SessionMessage](1) + read_stream_send, read_stream_receive = anyio.create_memory_object_stream[SessionMessage](1) + + # Create the session + session = TestSession( + read_stream_receive, + write_stream_send, + object, # Request type doesn't matter for this test + object, # Notification type doesn't matter for this test + ) + + # Create a test request + request = ClientRequest(PingRequest()) + + # Patch the _write_stream.send method to raise an exception + async def mock_send(*args: Any, **kwargs: Any): + raise RuntimeError("Simulated network error") + + # Record the response streams before the test + initial_stream_count = len(session._response_streams) + + # Run the test with the patched method + with patch.object(session._write_stream, "send", mock_send): + with pytest.raises(RuntimeError): + await session.send_request(request, EmptyResult) + + # Verify that no response streams were leaked + assert len(session._response_streams) == initial_stream_count, ( + f"Expected {initial_stream_count} response streams after request, but found {len(session._response_streams)}" + ) + + # Clean up + await write_stream_send.aclose() + await write_stream_receive.aclose() + await read_stream_send.aclose() + await read_stream_receive.aclose() diff --git a/tests/client/test_sampling_callback.py b/tests/client/test_sampling_callback.py index ba586d4a87..733364a767 100644 --- a/tests/client/test_sampling_callback.py +++ b/tests/client/test_sampling_callback.py @@ -8,8 +8,10 @@ from mcp.types import ( CreateMessageRequestParams, CreateMessageResult, + CreateMessageResultWithTools, SamplingMessage, TextContent, + ToolUseContent, ) @@ -21,9 +23,7 @@ async def test_sampling_callback(): callback_return = CreateMessageResult( role="assistant", - content=TextContent( - type="text", text="This is a response from the sampling callback" - ), + content=TextContent(type="text", text="This is a response from the sampling callback"), model="test-model", stopReason="endTurn", ) @@ -37,24 +37,16 @@ async def sampling_callback( @server.tool("test_sampling") async def test_sampling_tool(message: str): value = await server.get_context().session.create_message( - messages=[ - SamplingMessage( - role="user", content=TextContent(type="text", text=message) - ) - ], + messages=[SamplingMessage(role="user", content=TextContent(type="text", text=message))], max_tokens=100, ) assert value == callback_return return True # Test with sampling callback - async with create_session( - server._mcp_server, sampling_callback=sampling_callback - ) as client_session: + async with create_session(server._mcp_server, sampling_callback=sampling_callback) as client_session: # Make a request to trigger sampling callback - result = await client_session.call_tool( - "test_sampling", {"message": "Test message for sampling"} - ) + result = await client_session.call_tool("test_sampling", {"message": "Test message for sampling"}) assert result.isError is False assert isinstance(result.content[0], TextContent) assert result.content[0].text == "true" @@ -62,12 +54,83 @@ async def test_sampling_tool(message: str): # Test without sampling callback async with create_session(server._mcp_server) as client_session: # Make a request to trigger sampling callback - result = await client_session.call_tool( - "test_sampling", {"message": "Test message for sampling"} - ) + result = await client_session.call_tool("test_sampling", {"message": "Test message for sampling"}) assert result.isError is True assert isinstance(result.content[0], TextContent) - assert ( - result.content[0].text - == "Error executing tool test_sampling: Sampling not supported" + assert result.content[0].text == "Error executing tool test_sampling: Sampling not supported" + + +@pytest.mark.anyio +async def test_create_message_backwards_compat_single_content(): + """Test backwards compatibility: create_message without tools returns single content.""" + from mcp.server.fastmcp import FastMCP + + server = FastMCP("test") + + # Callback returns single content (text) + callback_return = CreateMessageResult( + role="assistant", + content=TextContent(type="text", text="Hello from LLM"), + model="test-model", + stopReason="endTurn", + ) + + async def sampling_callback( + context: RequestContext[ClientSession, None], + params: CreateMessageRequestParams, + ) -> CreateMessageResult: + return callback_return + + @server.tool("test_backwards_compat") + async def test_tool(message: str): + # Call create_message WITHOUT tools + result = await server.get_context().session.create_message( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text=message))], + max_tokens=100, ) + # Backwards compat: result should be CreateMessageResult + assert isinstance(result, CreateMessageResult) + # Content should be single (not a list) - this is the key backwards compat check + assert isinstance(result.content, TextContent) + assert result.content.text == "Hello from LLM" + # CreateMessageResult should NOT have content_as_list (that's on WithTools) + assert not hasattr(result, "content_as_list") or not callable(getattr(result, "content_as_list", None)) + return True + + async with create_session(server._mcp_server, sampling_callback=sampling_callback) as client_session: + result = await client_session.call_tool("test_backwards_compat", {"message": "Test"}) + assert result.isError is False + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "true" + + +@pytest.mark.anyio +async def test_create_message_result_with_tools_type(): + """Test that CreateMessageResultWithTools supports content_as_list.""" + # Test the type itself, not the overload (overload requires client capability setup) + result = CreateMessageResultWithTools( + role="assistant", + content=ToolUseContent(type="tool_use", id="call_123", name="get_weather", input={"city": "SF"}), + model="test-model", + stopReason="toolUse", + ) + + # CreateMessageResultWithTools should have content_as_list + content_list = result.content_as_list + assert len(content_list) == 1 + assert content_list[0].type == "tool_use" + + # It should also work with array content + result_array = CreateMessageResultWithTools( + role="assistant", + content=[ + TextContent(type="text", text="Let me check the weather"), + ToolUseContent(type="tool_use", id="call_456", name="get_weather", input={"city": "NYC"}), + ], + model="test-model", + stopReason="toolUse", + ) + content_list_array = result_array.content_as_list + assert len(content_list_array) == 2 + assert content_list_array[0].type == "text" + assert content_list_array[1].type == "tool_use" diff --git a/tests/client/test_scope_bug_1630.py b/tests/client/test_scope_bug_1630.py new file mode 100644 index 0000000000..7884718c1e --- /dev/null +++ b/tests/client/test_scope_bug_1630.py @@ -0,0 +1,166 @@ +""" +Regression test for issue #1630: OAuth2 scope incorrectly set to resource_metadata URL. + +This test verifies that when a 401 response contains both resource_metadata and scope +in the WWW-Authenticate header, the actual scope is used (not the resource_metadata URL). +""" + +from unittest import mock + +import httpx +import pytest +from pydantic import AnyUrl + +from mcp.client.auth import OAuthClientProvider +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class MockTokenStorage: + """Mock token storage for testing.""" + + def __init__(self) -> None: + self._tokens: OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens # pragma: no cover + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info # pragma: no cover + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info # pragma: no cover + + +@pytest.mark.anyio +async def test_401_uses_www_auth_scope_not_resource_metadata_url(): + """ + Regression test for #1630: Ensure scope is extracted from WWW-Authenticate header, + not the resource_metadata URL. + + When a 401 response contains: + WWW-Authenticate: Bearer resource_metadata="https://...", scope="read write" + + The client should use "read write" as the scope, NOT the resource_metadata URL. + """ + + async def redirect_handler(url: str) -> None: + pass # pragma: no cover + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", "test_state" # pragma: no cover + + client_metadata = OAuthClientMetadata( + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + client_name="Test Client", + ) + + provider = OAuthClientProvider( + server_url="https://api.example.com/mcp", + client_metadata=client_metadata, + storage=MockTokenStorage(), + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + + provider.context.current_tokens = None + provider.context.token_expiry_time = None + provider._initialized = True + + # Pre-set client info to skip DCR + provider.context.client_info = OAuthClientInformationFull( + client_id="test_client", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + + test_request = httpx.Request("GET", "https://api.example.com/mcp") + auth_flow = provider.async_auth_flow(test_request) + + # First request (no auth header yet) + await auth_flow.__anext__() + + # 401 response with BOTH resource_metadata URL and scope in WWW-Authenticate + # This is the key: the bug would use the URL as scope instead of "read write" + resource_metadata_url = "https://api.example.com/.well-known/oauth-protected-resource" + expected_scope = "read write" + + response_401 = httpx.Response( + 401, + headers={"WWW-Authenticate": (f'Bearer resource_metadata="{resource_metadata_url}", scope="{expected_scope}"')}, + request=test_request, + ) + + # Send 401, expect PRM discovery request + prm_request = await auth_flow.asend(response_401) + assert ".well-known/oauth-protected-resource" in str(prm_request.url) + + # PRM response with scopes_supported (these should be overridden by WWW-Auth scope) + prm_response = httpx.Response( + 200, + content=( + b'{"resource": "https://api.example.com/mcp", ' + b'"authorization_servers": ["https://auth.example.com"], ' + b'"scopes_supported": ["fallback:scope1", "fallback:scope2"]}' + ), + request=prm_request, + ) + + # Send PRM response, expect OAuth metadata discovery + oauth_metadata_request = await auth_flow.asend(prm_response) + assert ".well-known/oauth-authorization-server" in str(oauth_metadata_request.url) + + # OAuth metadata response + oauth_metadata_response = httpx.Response( + 200, + content=( + b'{"issuer": "https://auth.example.com", ' + b'"authorization_endpoint": "https://auth.example.com/authorize", ' + b'"token_endpoint": "https://auth.example.com/token"}' + ), + request=oauth_metadata_request, + ) + + # Mock authorization to skip interactive flow + provider._perform_authorization_code_grant = mock.AsyncMock(return_value=("test_auth_code", "test_code_verifier")) + + # Send OAuth metadata response, expect token request + token_request = await auth_flow.asend(oauth_metadata_response) + assert "token" in str(token_request.url) + + # NOW CHECK: The scope should be the WWW-Authenticate scope, NOT the URL + # This is where the bug manifested - scope was set to resource_metadata_url + actual_scope = provider.context.client_metadata.scope + + # This assertion would FAIL on main (scope would be the URL) + # but PASS on the fix branch (scope is "read write") + assert actual_scope == expected_scope, ( + f"Expected scope to be '{expected_scope}' from WWW-Authenticate header, " + f"but got '{actual_scope}'. " + f"If scope is '{resource_metadata_url}', the bug from #1630 is present." + ) + + # Verify it's definitely not the URL (explicit check for the bug) + assert actual_scope != resource_metadata_url, ( + f"BUG #1630: Scope was incorrectly set to resource_metadata URL '{resource_metadata_url}' " + f"instead of the actual scope '{expected_scope}'" + ) + + # Complete the flow to properly release the lock + token_response = httpx.Response( + 200, + content=b'{"access_token": "test_token", "token_type": "Bearer", "expires_in": 3600}', + request=token_request, + ) + + final_request = await auth_flow.asend(token_response) + assert final_request.headers["Authorization"] == "Bearer test_token" + + # Finish the flow + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 543ebb2f00..eb2683fbdb 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -1,11 +1,17 @@ +from typing import Any + import anyio import pytest import mcp.types as types from mcp.client.session import DEFAULT_CLIENT_INFO, ClientSession +from mcp.shared.context import RequestContext +from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder +from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS from mcp.types import ( LATEST_PROTOCOL_VERSION, + CallToolResult, ClientNotification, ClientRequest, Implementation, @@ -18,24 +24,23 @@ JSONRPCResponse, ServerCapabilities, ServerResult, + TextContent, ) @pytest.mark.anyio async def test_client_session_initialize(): - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[ - JSONRPCMessage - ](1) - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[ - JSONRPCMessage - ](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) initialized_notification = None + result = None async def mock_server(): nonlocal initialized_notification - jsonrpc_request = await client_to_server_receive.receive() + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message assert isinstance(jsonrpc_request.root, JSONRPCRequest) request = ClientRequest.model_validate( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) @@ -59,29 +64,26 @@ async def mock_server(): async with server_to_client_send: await server_to_client_send.send( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump( - by_alias=True, mode="json", exclude_none=True - ), + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) ) ) ) - jsonrpc_notification = await client_to_server_receive.receive() + session_notification = await client_to_server_receive.receive() + jsonrpc_notification = session_notification.message assert isinstance(jsonrpc_notification.root, JSONRPCNotification) initialized_notification = ClientNotification.model_validate( - jsonrpc_notification.model_dump( - by_alias=True, mode="json", exclude_none=True - ) + jsonrpc_notification.model_dump(by_alias=True, mode="json", exclude_none=True) ) # Create a message handler to catch exceptions - async def message_handler( - message: RequestResponder[types.ServerRequest, types.ClientResult] - | types.ServerNotification - | Exception, + async def message_handler( # pragma: no cover + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, ) -> None: if isinstance(message, Exception): raise message @@ -115,12 +117,8 @@ async def message_handler( @pytest.mark.anyio async def test_client_session_custom_client_info(): - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[ - JSONRPCMessage - ](1) - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[ - JSONRPCMessage - ](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) custom_client_info = Implementation(name="test-client", version="1.2.3") received_client_info = None @@ -128,7 +126,8 @@ async def test_client_session_custom_client_info(): async def mock_server(): nonlocal received_client_info - jsonrpc_request = await client_to_server_receive.receive() + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message assert isinstance(jsonrpc_request.root, JSONRPCRequest) request = ClientRequest.model_validate( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) @@ -146,13 +145,13 @@ async def mock_server(): async with server_to_client_send: await server_to_client_send.send( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump( - by_alias=True, mode="json", exclude_none=True - ), + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) ) ) ) @@ -180,19 +179,16 @@ async def mock_server(): @pytest.mark.anyio async def test_client_session_default_client_info(): - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[ - JSONRPCMessage - ](1) - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[ - JSONRPCMessage - ](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) received_client_info = None async def mock_server(): nonlocal received_client_info - jsonrpc_request = await client_to_server_receive.receive() + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message assert isinstance(jsonrpc_request.root, JSONRPCRequest) request = ClientRequest.model_validate( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) @@ -210,13 +206,13 @@ async def mock_server(): async with server_to_client_send: await server_to_client_send.send( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump( - by_alias=True, mode="json", exclude_none=True - ), + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) ) ) ) @@ -239,3 +235,536 @@ async def mock_server(): # Assert that the default client info was sent assert received_client_info == DEFAULT_CLIENT_INFO + + +@pytest.mark.anyio +async def test_client_session_version_negotiation_success(): + """Test successful version negotiation with supported version""" + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + result = None + + async def mock_server(): + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + + # Verify client sent the latest protocol version + assert request.root.params.protocolVersion == LATEST_PROTOCOL_VERSION + + # Server responds with a supported older version + result = ServerResult( + InitializeResult( + protocolVersion="2024-11-05", + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + async with server_to_client_send: + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + # Receive initialized notification + await client_to_server_receive.receive() + + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + ) as session, + anyio.create_task_group() as tg, + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + tg.start_soon(mock_server) + result = await session.initialize() + + # Assert the result with negotiated version + assert isinstance(result, InitializeResult) + assert result.protocolVersion == "2024-11-05" + assert result.protocolVersion in SUPPORTED_PROTOCOL_VERSIONS + + +@pytest.mark.anyio +async def test_client_session_version_negotiation_failure(): + """Test version negotiation failure with unsupported version""" + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + + async def mock_server(): + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + + # Server responds with an unsupported version + result = ServerResult( + InitializeResult( + protocolVersion="2020-01-01", # Unsupported old version + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + async with server_to_client_send: + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + ) as session, + anyio.create_task_group() as tg, + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + tg.start_soon(mock_server) + + # Should raise RuntimeError for unsupported version + with pytest.raises(RuntimeError, match="Unsupported protocol version"): + await session.initialize() + + +@pytest.mark.anyio +async def test_client_capabilities_default(): + """Test that client capabilities are properly set with default callbacks""" + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + + received_capabilities = None + + async def mock_server(): + nonlocal received_capabilities + + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + received_capabilities = request.root.params.capabilities + + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + async with server_to_client_send: + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + # Receive initialized notification + await client_to_server_receive.receive() + + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + ) as session, + anyio.create_task_group() as tg, + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + tg.start_soon(mock_server) + await session.initialize() + + # Assert that capabilities are properly set with defaults + assert received_capabilities is not None + assert received_capabilities.sampling is None # No custom sampling callback + assert received_capabilities.roots is None # No custom list_roots callback + + +@pytest.mark.anyio +async def test_client_capabilities_with_custom_callbacks(): + """Test that client capabilities are properly set with custom callbacks""" + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + + received_capabilities = None + + async def custom_sampling_callback( # pragma: no cover + context: RequestContext["ClientSession", Any], + params: types.CreateMessageRequestParams, + ) -> types.CreateMessageResult | types.ErrorData: + return types.CreateMessageResult( + role="assistant", + content=types.TextContent(type="text", text="test"), + model="test-model", + ) + + async def custom_list_roots_callback( # pragma: no cover + context: RequestContext["ClientSession", Any], + ) -> types.ListRootsResult | types.ErrorData: + return types.ListRootsResult(roots=[]) + + async def mock_server(): + nonlocal received_capabilities + + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + received_capabilities = request.root.params.capabilities + + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + async with server_to_client_send: + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + # Receive initialized notification + await client_to_server_receive.receive() + + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + sampling_callback=custom_sampling_callback, + list_roots_callback=custom_list_roots_callback, + ) as session, + anyio.create_task_group() as tg, + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + tg.start_soon(mock_server) + await session.initialize() + + # Assert that capabilities are properly set with custom callbacks + assert received_capabilities is not None + # Custom sampling callback provided + assert received_capabilities.sampling is not None + assert isinstance(received_capabilities.sampling, types.SamplingCapability) + # Default sampling capabilities (no tools) + assert received_capabilities.sampling.tools is None + # Custom list_roots callback provided + assert received_capabilities.roots is not None + assert isinstance(received_capabilities.roots, types.RootsCapability) + # Should be True for custom callback + assert received_capabilities.roots.listChanged is True + + +@pytest.mark.anyio +async def test_client_capabilities_with_sampling_tools(): + """Test that sampling capabilities with tools are properly advertised""" + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + + received_capabilities = None + + async def custom_sampling_callback( # pragma: no cover + context: RequestContext["ClientSession", Any], + params: types.CreateMessageRequestParams, + ) -> types.CreateMessageResult | types.ErrorData: + return types.CreateMessageResult( + role="assistant", + content=types.TextContent(type="text", text="test"), + model="test-model", + ) + + async def mock_server(): + nonlocal received_capabilities + + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + received_capabilities = request.root.params.capabilities + + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + async with server_to_client_send: + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + # Receive initialized notification + await client_to_server_receive.receive() + + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + sampling_callback=custom_sampling_callback, + sampling_capabilities=types.SamplingCapability(tools=types.SamplingToolsCapability()), + ) as session, + anyio.create_task_group() as tg, + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + tg.start_soon(mock_server) + await session.initialize() + + # Assert that sampling capabilities with tools are properly advertised + assert received_capabilities is not None + assert received_capabilities.sampling is not None + assert isinstance(received_capabilities.sampling, types.SamplingCapability) + # Tools capability should be present + assert received_capabilities.sampling.tools is not None + assert isinstance(received_capabilities.sampling.tools, types.SamplingToolsCapability) + + +@pytest.mark.anyio +async def test_get_server_capabilities(): + """Test that get_server_capabilities returns None before init and capabilities after""" + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + + expected_capabilities = ServerCapabilities( + logging=types.LoggingCapability(), + prompts=types.PromptsCapability(listChanged=True), + resources=types.ResourcesCapability(subscribe=True, listChanged=True), + tools=types.ToolsCapability(listChanged=False), + ) + + async def mock_server(): + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=expected_capabilities, + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + async with server_to_client_send: + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + await client_to_server_receive.receive() + + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + ) as session, + anyio.create_task_group() as tg, + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + assert session.get_server_capabilities() is None + + tg.start_soon(mock_server) + await session.initialize() + + capabilities = session.get_server_capabilities() + assert capabilities is not None + assert capabilities == expected_capabilities + assert capabilities.logging is not None + assert capabilities.prompts is not None + assert capabilities.prompts.listChanged is True + assert capabilities.resources is not None + assert capabilities.resources.subscribe is True + assert capabilities.tools is not None + assert capabilities.tools.listChanged is False + + +@pytest.mark.anyio +@pytest.mark.parametrize(argnames="meta", argvalues=[None, {"toolMeta": "value"}]) +async def test_client_tool_call_with_meta(meta: dict[str, Any] | None): + """Test that client tool call requests can include metadata""" + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + + mocked_tool = types.Tool(name="sample_tool", inputSchema={}) + + async def mock_server(): + # Receive initialization request from client + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + # Answer initialization request + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + + # Receive initialized notification + await client_to_server_receive.receive() + + # Wait for the client to send a 'tools/call' request + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + + assert jsonrpc_request.root.method == "tools/call" + + if meta is not None: + assert jsonrpc_request.root.params + assert "_meta" in jsonrpc_request.root.params + assert jsonrpc_request.root.params["_meta"] == meta + + result = ServerResult( + CallToolResult(content=[TextContent(type="text", text="Called successfully")], isError=False) + ) + + # Send the tools/call result + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + + # Wait for the tools/list request from the client + # The client requires this step to validate the tool output schema + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + + assert jsonrpc_request.root.method == "tools/list" + + result = types.ListToolsResult(tools=[mocked_tool]) + + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + + server_to_client_send.close() + + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + ) as session, + anyio.create_task_group() as tg, + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + tg.start_soon(mock_server) + + await session.initialize() + + await session.call_tool(name=mocked_tool.name, arguments={"foo": "bar"}, meta=meta) diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py new file mode 100644 index 0000000000..b03fe9ca88 --- /dev/null +++ b/tests/client/test_session_group.py @@ -0,0 +1,385 @@ +import contextlib +from unittest import mock + +import pytest + +import mcp +from mcp import types +from mcp.client.session_group import ( + ClientSessionGroup, + ClientSessionParameters, + SseServerParameters, + StreamableHttpParameters, +) +from mcp.client.stdio import StdioServerParameters +from mcp.shared.exceptions import McpError + + +@pytest.fixture +def mock_exit_stack(): + """Fixture for a mocked AsyncExitStack.""" + # Use unittest.mock.Mock directly if needed, or just a plain object + # if only attribute access/existence is needed. + # For AsyncExitStack, Mock or MagicMock is usually fine. + return mock.MagicMock(spec=contextlib.AsyncExitStack) + + +@pytest.mark.anyio +class TestClientSessionGroup: + def test_init(self): + mcp_session_group = ClientSessionGroup() + assert not mcp_session_group._tools + assert not mcp_session_group._resources + assert not mcp_session_group._prompts + assert not mcp_session_group._tool_to_session + + def test_component_properties(self): + # --- Mock Dependencies --- + mock_prompt = mock.Mock() + mock_resource = mock.Mock() + mock_tool = mock.Mock() + + # --- Prepare Session Group --- + mcp_session_group = ClientSessionGroup() + mcp_session_group._prompts = {"my_prompt": mock_prompt} + mcp_session_group._resources = {"my_resource": mock_resource} + mcp_session_group._tools = {"my_tool": mock_tool} + + # --- Assertions --- + assert mcp_session_group.prompts == {"my_prompt": mock_prompt} + assert mcp_session_group.resources == {"my_resource": mock_resource} + assert mcp_session_group.tools == {"my_tool": mock_tool} + + async def test_call_tool(self): + # --- Mock Dependencies --- + mock_session = mock.AsyncMock() + + # --- Prepare Session Group --- + def hook(name: str, server_info: types.Implementation) -> str: # pragma: no cover + return f"{(server_info.name)}-{name}" + + mcp_session_group = ClientSessionGroup(component_name_hook=hook) + mcp_session_group._tools = {"server1-my_tool": types.Tool(name="my_tool", inputSchema={})} + mcp_session_group._tool_to_session = {"server1-my_tool": mock_session} + text_content = types.TextContent(type="text", text="OK") + mock_session.call_tool.return_value = types.CallToolResult(content=[text_content]) + + # --- Test Execution --- + result = await mcp_session_group.call_tool( + name="server1-my_tool", + arguments={ + "name": "value1", + "args": {}, + }, + ) + + # --- Assertions --- + assert result.content == [text_content] + mock_session.call_tool.assert_called_once_with( + "my_tool", + {"name": "value1", "args": {}}, + read_timeout_seconds=None, + progress_callback=None, + meta=None, + ) + + async def test_connect_to_server(self, mock_exit_stack: contextlib.AsyncExitStack): + """Test connecting to a server and aggregating components.""" + # --- Mock Dependencies --- + mock_server_info = mock.Mock(spec=types.Implementation) + mock_server_info.name = "TestServer1" + mock_session = mock.AsyncMock(spec=mcp.ClientSession) + mock_tool1 = mock.Mock(spec=types.Tool) + mock_tool1.name = "tool_a" + mock_resource1 = mock.Mock(spec=types.Resource) + mock_resource1.name = "resource_b" + mock_prompt1 = mock.Mock(spec=types.Prompt) + mock_prompt1.name = "prompt_c" + mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool1]) + mock_session.list_resources.return_value = mock.AsyncMock(resources=[mock_resource1]) + mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[mock_prompt1]) + + # --- Test Execution --- + group = ClientSessionGroup(exit_stack=mock_exit_stack) + with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)): + await group.connect_to_server(StdioServerParameters(command="test")) + + # --- Assertions --- + assert mock_session in group._sessions + assert len(group.tools) == 1 + assert "tool_a" in group.tools + assert group.tools["tool_a"] == mock_tool1 + assert group._tool_to_session["tool_a"] == mock_session + assert len(group.resources) == 1 + assert "resource_b" in group.resources + assert group.resources["resource_b"] == mock_resource1 + assert len(group.prompts) == 1 + assert "prompt_c" in group.prompts + assert group.prompts["prompt_c"] == mock_prompt1 + mock_session.list_tools.assert_awaited_once() + mock_session.list_resources.assert_awaited_once() + mock_session.list_prompts.assert_awaited_once() + + async def test_connect_to_server_with_name_hook(self, mock_exit_stack: contextlib.AsyncExitStack): + """Test connecting with a component name hook.""" + # --- Mock Dependencies --- + mock_server_info = mock.Mock(spec=types.Implementation) + mock_server_info.name = "HookServer" + mock_session = mock.AsyncMock(spec=mcp.ClientSession) + mock_tool = mock.Mock(spec=types.Tool) + mock_tool.name = "base_tool" + mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool]) + mock_session.list_resources.return_value = mock.AsyncMock(resources=[]) + mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[]) + + # --- Test Setup --- + def name_hook(name: str, server_info: types.Implementation) -> str: + return f"{server_info.name}.{name}" + + # --- Test Execution --- + group = ClientSessionGroup(exit_stack=mock_exit_stack, component_name_hook=name_hook) + with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)): + await group.connect_to_server(StdioServerParameters(command="test")) + + # --- Assertions --- + assert mock_session in group._sessions + assert len(group.tools) == 1 + expected_tool_name = "HookServer.base_tool" + assert expected_tool_name in group.tools + assert group.tools[expected_tool_name] == mock_tool + assert group._tool_to_session[expected_tool_name] == mock_session + + async def test_disconnect_from_server(self): # No mock arguments needed + """Test disconnecting from a server.""" + # --- Test Setup --- + group = ClientSessionGroup() + server_name = "ServerToDisconnect" + + # Manually populate state using standard mocks + mock_session1 = mock.MagicMock(spec=mcp.ClientSession) + mock_session2 = mock.MagicMock(spec=mcp.ClientSession) + mock_tool1 = mock.Mock(spec=types.Tool) + mock_tool1.name = "tool1" + mock_resource1 = mock.Mock(spec=types.Resource) + mock_resource1.name = "res1" + mock_prompt1 = mock.Mock(spec=types.Prompt) + mock_prompt1.name = "prm1" + mock_tool2 = mock.Mock(spec=types.Tool) + mock_tool2.name = "tool2" + mock_component_named_like_server = mock.Mock() + mock_session = mock.Mock(spec=mcp.ClientSession) + + group._tools = { + "tool1": mock_tool1, + "tool2": mock_tool2, + server_name: mock_component_named_like_server, + } + group._tool_to_session = { + "tool1": mock_session1, + "tool2": mock_session2, + server_name: mock_session1, + } + group._resources = { + "res1": mock_resource1, + server_name: mock_component_named_like_server, + } + group._prompts = { + "prm1": mock_prompt1, + server_name: mock_component_named_like_server, + } + group._sessions = { + mock_session: ClientSessionGroup._ComponentNames( + prompts=set({"prm1"}), + resources=set({"res1"}), + tools=set({"tool1", "tool2"}), + ) + } + + # --- Assertions --- + assert mock_session in group._sessions + assert "tool1" in group._tools + assert "tool2" in group._tools + assert "res1" in group._resources + assert "prm1" in group._prompts + + # --- Test Execution --- + await group.disconnect_from_server(mock_session) + + # --- Assertions --- + assert mock_session not in group._sessions + assert "tool1" not in group._tools + assert "tool2" not in group._tools + assert "res1" not in group._resources + assert "prm1" not in group._prompts + + async def test_connect_to_server_duplicate_tool_raises_error(self, mock_exit_stack: contextlib.AsyncExitStack): + """Test McpError raised when connecting a server with a dup name.""" + # --- Setup Pre-existing State --- + group = ClientSessionGroup(exit_stack=mock_exit_stack) + existing_tool_name = "shared_tool" + # Manually add a tool to simulate a previous connection + group._tools[existing_tool_name] = mock.Mock(spec=types.Tool) + group._tools[existing_tool_name].name = existing_tool_name + # Need a dummy session associated with the existing tool + mock_session = mock.MagicMock(spec=mcp.ClientSession) + group._tool_to_session[existing_tool_name] = mock_session + group._session_exit_stacks[mock_session] = mock.Mock(spec=contextlib.AsyncExitStack) + + # --- Mock New Connection Attempt --- + mock_server_info_new = mock.Mock(spec=types.Implementation) + mock_server_info_new.name = "ServerWithDuplicate" + mock_session_new = mock.AsyncMock(spec=mcp.ClientSession) + + # Configure the new session to return a tool with the *same name* + duplicate_tool = mock.Mock(spec=types.Tool) + duplicate_tool.name = existing_tool_name + mock_session_new.list_tools.return_value = mock.AsyncMock(tools=[duplicate_tool]) + # Keep other lists empty for simplicity + mock_session_new.list_resources.return_value = mock.AsyncMock(resources=[]) + mock_session_new.list_prompts.return_value = mock.AsyncMock(prompts=[]) + + # --- Test Execution and Assertion --- + with pytest.raises(McpError) as excinfo: + with mock.patch.object( + group, + "_establish_session", + return_value=(mock_server_info_new, mock_session_new), + ): + await group.connect_to_server(StdioServerParameters(command="test")) + + # Assert details about the raised error + assert excinfo.value.error.code == types.INVALID_PARAMS + assert existing_tool_name in excinfo.value.error.message + assert "already exist " in excinfo.value.error.message + + # Verify the duplicate tool was *not* added again (state should be unchanged) + assert len(group._tools) == 1 # Should still only have the original + assert group._tools[existing_tool_name] is not duplicate_tool # Ensure it's the original mock + + # No patching needed here + async def test_disconnect_non_existent_server(self): + """Test disconnecting a server that isn't connected.""" + session = mock.Mock(spec=mcp.ClientSession) + group = ClientSessionGroup() + with pytest.raises(McpError): + await group.disconnect_from_server(session) + + @pytest.mark.parametrize( + "server_params_instance, client_type_name, patch_target_for_client_func", + [ + ( + StdioServerParameters(command="test_stdio_cmd"), + "stdio", + "mcp.client.session_group.mcp.stdio_client", + ), + ( + SseServerParameters(url="http://test.com/sse", timeout=10.0), + "sse", + "mcp.client.session_group.sse_client", + ), # url, headers, timeout, sse_read_timeout + ( + StreamableHttpParameters(url="http://test.com/stream", terminate_on_close=False), + "streamablehttp", + "mcp.client.session_group.streamable_http_client", + ), # url, headers, timeout, sse_read_timeout, terminate_on_close + ], + ) + async def test_establish_session_parameterized( + self, + server_params_instance: StdioServerParameters | SseServerParameters | StreamableHttpParameters, + client_type_name: str, # Just for clarity or conditional logic if needed + patch_target_for_client_func: str, + ): + with mock.patch("mcp.client.session_group.mcp.ClientSession") as mock_ClientSession_class: + with mock.patch(patch_target_for_client_func) as mock_specific_client_func: + mock_client_cm_instance = mock.AsyncMock(name=f"{client_type_name}ClientCM") + mock_read_stream = mock.AsyncMock(name=f"{client_type_name}Read") + mock_write_stream = mock.AsyncMock(name=f"{client_type_name}Write") + + # streamable_http_client's __aenter__ returns three values + if client_type_name == "streamablehttp": + mock_extra_stream_val = mock.AsyncMock(name="StreamableExtra") + mock_client_cm_instance.__aenter__.return_value = ( + mock_read_stream, + mock_write_stream, + mock_extra_stream_val, + ) + else: + mock_client_cm_instance.__aenter__.return_value = ( + mock_read_stream, + mock_write_stream, + ) + + mock_client_cm_instance.__aexit__ = mock.AsyncMock(return_value=None) + mock_specific_client_func.return_value = mock_client_cm_instance + + # --- Mock mcp.ClientSession (class) --- + # mock_ClientSession_class is already provided by the outer patch + mock_raw_session_cm = mock.AsyncMock(name="RawSessionCM") + mock_ClientSession_class.return_value = mock_raw_session_cm + + mock_entered_session = mock.AsyncMock(name="EnteredSessionInstance") + mock_raw_session_cm.__aenter__.return_value = mock_entered_session + mock_raw_session_cm.__aexit__ = mock.AsyncMock(return_value=None) + + # Mock session.initialize() + mock_initialize_result = mock.AsyncMock(name="InitializeResult") + mock_initialize_result.serverInfo = types.Implementation(name="foo", version="1") + mock_entered_session.initialize.return_value = mock_initialize_result + + # --- Test Execution --- + group = ClientSessionGroup() + returned_server_info = None + returned_session = None + + async with contextlib.AsyncExitStack() as stack: + group._exit_stack = stack + ( + returned_server_info, + returned_session, + ) = await group._establish_session(server_params_instance, ClientSessionParameters()) + + # --- Assertions --- + # 1. Assert the correct specific client function was called + if client_type_name == "stdio": + assert isinstance(server_params_instance, StdioServerParameters) + mock_specific_client_func.assert_called_once_with(server_params_instance) + elif client_type_name == "sse": + assert isinstance(server_params_instance, SseServerParameters) + mock_specific_client_func.assert_called_once_with( + url=server_params_instance.url, + headers=server_params_instance.headers, + timeout=server_params_instance.timeout, + sse_read_timeout=server_params_instance.sse_read_timeout, + ) + elif client_type_name == "streamablehttp": # pragma: no branch + assert isinstance(server_params_instance, StreamableHttpParameters) + # Verify streamable_http_client was called with url, httpx_client, and terminate_on_close + # The http_client is created by the real create_mcp_http_client + import httpx + + call_args = mock_specific_client_func.call_args + assert call_args.kwargs["url"] == server_params_instance.url + assert call_args.kwargs["terminate_on_close"] == server_params_instance.terminate_on_close + assert isinstance(call_args.kwargs["http_client"], httpx.AsyncClient) + + mock_client_cm_instance.__aenter__.assert_awaited_once() + + # 2. Assert ClientSession was called correctly + mock_ClientSession_class.assert_called_once_with( + mock_read_stream, + mock_write_stream, + read_timeout_seconds=None, + sampling_callback=None, + elicitation_callback=None, + list_roots_callback=None, + logging_callback=None, + message_handler=None, + client_info=None, + ) + mock_raw_session_cm.__aenter__.assert_awaited_once() + mock_entered_session.initialize.assert_awaited_once() + + # 3. Assert returned values + assert returned_server_info is mock_initialize_result.serverInfo + assert returned_session is mock_entered_session diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 95747ffd19..ba58da7321 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -1,16 +1,42 @@ +import errno +import os import shutil +import sys +import tempfile +import textwrap +import time +import anyio import pytest -from mcp.client.stdio import StdioServerParameters, stdio_client -from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, _create_platform_compatible_process, stdio_client +from mcp.shared.exceptions import McpError +from mcp.shared.message import SessionMessage +from mcp.types import CONNECTION_CLOSED, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse -tee: str = shutil.which("tee") # type: ignore +from ..shared.test_win32_utils import escape_path_for_python + +# Timeout for cleanup of processes that ignore SIGTERM +# This timeout ensures the test fails quickly if the cleanup logic doesn't have +# proper fallback mechanisms (SIGINT/SIGKILL) for processes that ignore SIGTERM +SIGTERM_IGNORING_PROCESS_TIMEOUT = 5.0 + +tee = shutil.which("tee") + + +@pytest.mark.anyio +@pytest.mark.skipif(tee is None, reason="could not find tee command") +async def test_stdio_context_manager_exiting(): + assert tee is not None + async with stdio_client(StdioServerParameters(command=tee)) as (_, _): + pass @pytest.mark.anyio @pytest.mark.skipif(tee is None, reason="could not find tee command") async def test_stdio_client(): + assert tee is not None server_parameters = StdioServerParameters(command=tee) async with stdio_client(server_parameters) as (read_stream, write_stream): @@ -22,22 +48,585 @@ async def test_stdio_client(): async with write_stream: for message in messages: - await write_stream.send(message) + session_message = SessionMessage(message) + await write_stream.send(session_message) - read_messages = [] + read_messages: list[JSONRPCMessage] = [] async with read_stream: async for message in read_stream: - if isinstance(message, Exception): + if isinstance(message, Exception): # pragma: no cover raise message - read_messages.append(message) + read_messages.append(message.message) if len(read_messages) == 2: break assert len(read_messages) == 2 - assert read_messages[0] == JSONRPCMessage( - root=JSONRPCRequest(jsonrpc="2.0", id=1, method="ping") + assert read_messages[0] == JSONRPCMessage(root=JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")) + assert read_messages[1] == JSONRPCMessage(root=JSONRPCResponse(jsonrpc="2.0", id=2, result={})) + + +@pytest.mark.anyio +async def test_stdio_client_bad_path(): + """Check that the connection doesn't hang if process errors.""" + server_params = StdioServerParameters(command=sys.executable, args=["-c", "non-existent-file.py"]) + async with stdio_client(server_params) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + # The session should raise an error when the connection closes + with pytest.raises(McpError) as exc_info: + await session.initialize() + + # Check that we got a connection closed error + assert exc_info.value.error.code == CONNECTION_CLOSED + assert "Connection closed" in exc_info.value.error.message + + +@pytest.mark.anyio +async def test_stdio_client_nonexistent_command(): + """Test that stdio_client raises an error for non-existent commands.""" + # Create a server with a non-existent command + server_params = StdioServerParameters( + command="/path/to/nonexistent/command", + args=["--help"], + ) + + # Should raise an error when trying to start the process + with pytest.raises(OSError) as exc_info: + async with stdio_client(server_params) as (_, _): + pass # pragma: no cover + + # The error should indicate the command was not found (ENOENT: No such file or directory) + assert exc_info.value.errno == errno.ENOENT + + +@pytest.mark.anyio +async def test_stdio_client_universal_cleanup(): + """ + Test that stdio_client completes cleanup within reasonable time + even when connected to processes that exit slowly. + """ + + # Use a Python script that simulates a long-running process + # This ensures consistent behavior across platforms + long_running_script = textwrap.dedent( + """ + import time + import sys + + # Simulate a long-running process + for i in range(100): + time.sleep(0.1) + # Flush to ensure output is visible + sys.stdout.flush() + sys.stderr.flush() + """ + ) + + server_params = StdioServerParameters( + command=sys.executable, + args=["-c", long_running_script], + ) + + start_time = time.time() + + with anyio.move_on_after(8.0) as cancel_scope: + async with stdio_client(server_params) as (_, _): + # Immediately exit - this triggers cleanup while process is still running + pass + + end_time = time.time() + elapsed = end_time - start_time + + # On Windows: 2s (stdin wait) + 2s (terminate wait) + overhead = ~5s expected + assert elapsed < 6.0, ( + f"stdio_client cleanup took {elapsed:.1f} seconds, expected < 6.0 seconds. " + f"This suggests the timeout mechanism may not be working properly." + ) + + # Check if we timed out + if cancel_scope.cancelled_caught: # pragma: no cover + pytest.fail( + "stdio_client cleanup timed out after 8.0 seconds. " + "This indicates the cleanup mechanism is hanging and needs fixing." ) - assert read_messages[1] == JSONRPCMessage( - root=JSONRPCResponse(jsonrpc="2.0", id=2, result={}) + + +@pytest.mark.anyio +@pytest.mark.skipif(sys.platform == "win32", reason="Windows signal handling is different") +async def test_stdio_client_sigint_only_process(): # pragma: no cover + """ + Test cleanup with a process that ignores SIGTERM but responds to SIGINT. + """ + # Create a Python script that ignores SIGTERM but handles SIGINT + script_content = textwrap.dedent( + """ + import signal + import sys + import time + + # Ignore SIGTERM (what process.terminate() sends) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + + # Handle SIGINT (Ctrl+C signal) by exiting cleanly + def sigint_handler(signum, frame): + sys.exit(0) + + signal.signal(signal.SIGINT, sigint_handler) + + # Keep running until SIGINT received + while True: + time.sleep(0.1) + """ + ) + + server_params = StdioServerParameters( + command=sys.executable, + args=["-c", script_content], + ) + + start_time = time.time() + + try: + # Use anyio timeout to prevent test from hanging forever + with anyio.move_on_after(5.0) as cancel_scope: + async with stdio_client(server_params) as (_, _): + # Let the process start and begin ignoring SIGTERM + await anyio.sleep(0.5) + # Exit context triggers cleanup - this should not hang + pass + + if cancel_scope.cancelled_caught: # pragma: no cover + raise TimeoutError("Test timed out") + + end_time = time.time() + elapsed = end_time - start_time + + # Should complete quickly even with SIGTERM-ignoring process + # This will fail if cleanup only uses process.terminate() without fallback + assert elapsed < SIGTERM_IGNORING_PROCESS_TIMEOUT, ( + f"stdio_client cleanup took {elapsed:.1f} seconds with SIGTERM-ignoring process. " + f"Expected < {SIGTERM_IGNORING_PROCESS_TIMEOUT} seconds. " + "This suggests the cleanup needs SIGINT/SIGKILL fallback." ) + except (TimeoutError, Exception) as e: # pragma: no cover + if isinstance(e, TimeoutError) or "timed out" in str(e): + pytest.fail( + f"stdio_client cleanup timed out after {SIGTERM_IGNORING_PROCESS_TIMEOUT} seconds " + "with SIGTERM-ignoring process. " + "This confirms the cleanup needs SIGINT/SIGKILL fallback for processes that ignore SIGTERM." + ) + else: + raise + + +class TestChildProcessCleanup: + """ + Tests for child process cleanup functionality using _terminate_process_tree. + + These tests verify that child processes are properly terminated when the parent + is killed, addressing the issue where processes like npx spawn child processes + that need to be cleaned up. The tests cover various process tree scenarios: + + - Basic parent-child relationship (single child process) + - Multi-level process trees (parent → child → grandchild) + - Race conditions where parent exits during cleanup + + Note on Windows ResourceWarning: + On Windows, we may see ResourceWarning about subprocess still running. This is + expected behavior due to how Windows process termination works: + - anyio's process.terminate() calls Windows TerminateProcess() API + - TerminateProcess() immediately kills the process without allowing cleanup + - subprocess.Popen objects in the killed process can't run their cleanup code + - Python detects this during garbage collection and issues a ResourceWarning + + This warning does NOT indicate a process leak - the processes are properly + terminated. It only means the Popen objects couldn't clean up gracefully. + This is a fundamental difference between Windows and Unix process termination. + """ + + @pytest.mark.anyio + @pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default") + async def test_basic_child_process_cleanup(self): + """ + Test basic parent-child process cleanup. + Parent spawns a single child process that writes continuously to a file. + """ + # Create a marker file for the child process to write to + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + marker_file = f.name + + # Also create a file to verify parent started + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + parent_marker = f.name + + try: + # Parent script that spawns a child process + parent_script = textwrap.dedent( + f""" + import subprocess + import sys + import time + import os + + # Mark that parent started + with open({escape_path_for_python(parent_marker)}, 'w') as f: + f.write('parent started\\n') + + # Child script that writes continuously + child_script = f''' + import time + with open({escape_path_for_python(marker_file)}, 'a') as f: + while True: + f.write(f"{time.time()}") + f.flush() + time.sleep(0.1) + ''' + + # Start the child process + child = subprocess.Popen([sys.executable, '-c', child_script]) + + # Parent just sleeps + while True: + time.sleep(0.1) + """ + ) + + print("\nStarting child process termination test...") + + # Start the parent process + proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script]) + + # Wait for processes to start + await anyio.sleep(0.5) + + # Verify parent started + assert os.path.exists(parent_marker), "Parent process didn't start" + + # Verify child is writing + if os.path.exists(marker_file): # pragma: no branch + initial_size = os.path.getsize(marker_file) + await anyio.sleep(0.3) + size_after_wait = os.path.getsize(marker_file) + assert size_after_wait > initial_size, "Child process should be writing" + print(f"Child is writing (file grew from {initial_size} to {size_after_wait} bytes)") + + # Terminate using our function + print("Terminating process and children...") + from mcp.client.stdio import _terminate_process_tree + + await _terminate_process_tree(proc) + + # Verify processes stopped + await anyio.sleep(0.5) + if os.path.exists(marker_file): # pragma: no branch + size_after_cleanup = os.path.getsize(marker_file) + await anyio.sleep(0.5) + final_size = os.path.getsize(marker_file) + + print(f"After cleanup: file size {size_after_cleanup} -> {final_size}") + assert final_size == size_after_cleanup, ( + f"Child process still running! File grew by {final_size - size_after_cleanup} bytes" + ) + + print("SUCCESS: Child process was properly terminated") + + finally: + # Clean up files + for f in [marker_file, parent_marker]: + try: + os.unlink(f) + except OSError: # pragma: no cover + pass + + @pytest.mark.anyio + @pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default") + async def test_nested_process_tree(self): + """ + Test nested process tree cleanup (parent → child → grandchild). + Each level writes to a different file to verify all processes are terminated. + """ + # Create temporary files for each process level + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f1: + parent_file = f1.name + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f2: + child_file = f2.name + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f3: + grandchild_file = f3.name + + try: + # Simple nested process tree test + # We create parent -> child -> grandchild, each writing to a file + parent_script = textwrap.dedent( + f""" + import subprocess + import sys + import time + import os + + # Child will spawn grandchild and write to child file + child_script = f'''import subprocess + import sys + import time + + # Grandchild just writes to file + grandchild_script = \"\"\"import time + with open({escape_path_for_python(grandchild_file)}, 'a') as f: + while True: + f.write(f"gc {{time.time()}}") + f.flush() + time.sleep(0.1)\"\"\" + + # Spawn grandchild + subprocess.Popen([sys.executable, '-c', grandchild_script]) + + # Child writes to its file + with open({escape_path_for_python(child_file)}, 'a') as f: + while True: + f.write(f"c {time.time()}") + f.flush() + time.sleep(0.1)''' + + # Spawn child process + subprocess.Popen([sys.executable, '-c', child_script]) + + # Parent writes to its file + with open({escape_path_for_python(parent_file)}, 'a') as f: + while True: + f.write(f"p {time.time()}") + f.flush() + time.sleep(0.1) + """ + ) + + # Start the parent process + proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script]) + + # Let all processes start + await anyio.sleep(1.0) + + # Verify all are writing + for file_path, name in [(parent_file, "parent"), (child_file, "child"), (grandchild_file, "grandchild")]: + if os.path.exists(file_path): # pragma: no branch + initial_size = os.path.getsize(file_path) + await anyio.sleep(0.3) + new_size = os.path.getsize(file_path) + assert new_size > initial_size, f"{name} process should be writing" + + # Terminate the whole tree + from mcp.client.stdio import _terminate_process_tree + + await _terminate_process_tree(proc) + + # Verify all stopped + await anyio.sleep(0.5) + for file_path, name in [(parent_file, "parent"), (child_file, "child"), (grandchild_file, "grandchild")]: + if os.path.exists(file_path): # pragma: no branch + size1 = os.path.getsize(file_path) + await anyio.sleep(0.3) + size2 = os.path.getsize(file_path) + assert size1 == size2, f"{name} still writing after cleanup!" + + print("SUCCESS: All processes in tree terminated") + + finally: + # Clean up all marker files + for f in [parent_file, child_file, grandchild_file]: + try: + os.unlink(f) + except OSError: # pragma: no cover + pass + + @pytest.mark.anyio + @pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default") + async def test_early_parent_exit(self): + """ + Test cleanup when parent exits during termination sequence. + Tests the race condition where parent might die during our termination + sequence but we can still clean up the children via the process group. + """ + # Create a temporary file for the child + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + marker_file = f.name + + try: + # Parent that spawns child and waits briefly + parent_script = textwrap.dedent( + f""" + import subprocess + import sys + import time + import signal + + # Child that continues running + child_script = f'''import time + with open({escape_path_for_python(marker_file)}, 'a') as f: + while True: + f.write(f"child {time.time()}") + f.flush() + time.sleep(0.1)''' + + # Start child in same process group + subprocess.Popen([sys.executable, '-c', child_script]) + + # Parent waits a bit then exits on SIGTERM + def handle_term(sig, frame): + sys.exit(0) + + signal.signal(signal.SIGTERM, handle_term) + + # Wait + while True: + time.sleep(0.1) + """ + ) + + # Start the parent process + proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script]) + + # Let child start writing + await anyio.sleep(0.5) + + # Verify child is writing + if os.path.exists(marker_file): # pragma: no cover + size1 = os.path.getsize(marker_file) + await anyio.sleep(0.3) + size2 = os.path.getsize(marker_file) + assert size2 > size1, "Child should be writing" + + # Terminate - this will kill the process group even if parent exits first + from mcp.client.stdio import _terminate_process_tree + + await _terminate_process_tree(proc) + + # Verify child stopped + await anyio.sleep(0.5) + if os.path.exists(marker_file): # pragma: no branch + size3 = os.path.getsize(marker_file) + await anyio.sleep(0.3) + size4 = os.path.getsize(marker_file) + assert size3 == size4, "Child should be terminated" + + print("SUCCESS: Child terminated even with parent exit during cleanup") + + finally: + # Clean up marker file + try: + os.unlink(marker_file) + except OSError: # pragma: no cover + pass + + +@pytest.mark.anyio +async def test_stdio_client_graceful_stdin_exit(): + """ + Test that a process exits gracefully when stdin is closed, + without needing SIGTERM or SIGKILL. + """ + # Create a Python script that exits when stdin is closed + script_content = textwrap.dedent( + """ + import sys + + # Read from stdin until it's closed + try: + while True: + line = sys.stdin.readline() + if not line: # EOF/stdin closed + break + except: + pass + + # Exit gracefully + sys.exit(0) + """ + ) + + server_params = StdioServerParameters( + command=sys.executable, + args=["-c", script_content], + ) + + start_time = time.time() + + # Use anyio timeout to prevent test from hanging forever + with anyio.move_on_after(5.0) as cancel_scope: + async with stdio_client(server_params) as (_, _): + # Let the process start and begin reading stdin + await anyio.sleep(0.2) + # Exit context triggers cleanup - process should exit from stdin closure + pass + + if cancel_scope.cancelled_caught: + pytest.fail( + "stdio_client cleanup timed out after 5.0 seconds. " + "Process should have exited gracefully when stdin was closed." + ) # pragma: no cover + + end_time = time.time() + elapsed = end_time - start_time + + # Should complete quickly with just stdin closure (no signals needed) + assert elapsed < 3.0, ( + f"stdio_client cleanup took {elapsed:.1f} seconds for stdin-aware process. " + f"Expected < 3.0 seconds since process should exit on stdin closure." + ) + + +@pytest.mark.anyio +async def test_stdio_client_stdin_close_ignored(): + """ + Test that when a process ignores stdin closure, the shutdown sequence + properly escalates to SIGTERM. + """ + # Create a Python script that ignores stdin closure but responds to SIGTERM + script_content = textwrap.dedent( + """ + import signal + import sys + import time + + # Set up SIGTERM handler to exit cleanly + def sigterm_handler(signum, frame): + sys.exit(0) + + signal.signal(signal.SIGTERM, sigterm_handler) + + # Close stdin immediately to simulate ignoring it + sys.stdin.close() + + # Keep running until SIGTERM + while True: + time.sleep(0.1) + """ + ) + + server_params = StdioServerParameters( + command=sys.executable, + args=["-c", script_content], + ) + + start_time = time.time() + + # Use anyio timeout to prevent test from hanging forever + with anyio.move_on_after(7.0) as cancel_scope: + async with stdio_client(server_params) as (_, _): + # Let the process start + await anyio.sleep(0.2) + # Exit context triggers cleanup + pass + + if cancel_scope.cancelled_caught: + pytest.fail( + "stdio_client cleanup timed out after 7.0 seconds. " + "Process should have been terminated via SIGTERM escalation." + ) # pragma: no cover + + end_time = time.time() + elapsed = end_time - start_time + + # Should take ~2 seconds (stdin close timeout) before SIGTERM is sent + # Total time should be between 2-4 seconds + assert 1.5 < elapsed < 4.5, ( + f"stdio_client cleanup took {elapsed:.1f} seconds for stdin-ignoring process. " + f"Expected between 2-4 seconds (2s stdin timeout + termination time)." + ) diff --git a/tests/experimental/__init__.py b/tests/experimental/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/experimental/tasks/__init__.py b/tests/experimental/tasks/__init__.py new file mode 100644 index 0000000000..6e8649d283 --- /dev/null +++ b/tests/experimental/tasks/__init__.py @@ -0,0 +1 @@ +"""Tests for MCP task support.""" diff --git a/tests/experimental/tasks/client/__init__.py b/tests/experimental/tasks/client/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/experimental/tasks/client/test_capabilities.py b/tests/experimental/tasks/client/test_capabilities.py new file mode 100644 index 0000000000..f2def4e3a6 --- /dev/null +++ b/tests/experimental/tasks/client/test_capabilities.py @@ -0,0 +1,331 @@ +"""Tests for client task capabilities declaration during initialization.""" + +import anyio +import pytest + +import mcp.types as types +from mcp import ClientCapabilities +from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers +from mcp.client.session import ClientSession +from mcp.shared.context import RequestContext +from mcp.shared.message import SessionMessage +from mcp.types import ( + LATEST_PROTOCOL_VERSION, + ClientRequest, + Implementation, + InitializeRequest, + InitializeResult, + JSONRPCMessage, + JSONRPCRequest, + JSONRPCResponse, + ServerCapabilities, + ServerResult, +) + + +@pytest.mark.anyio +async def test_client_capabilities_without_tasks(): + """Test that tasks capability is None when not provided.""" + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + + received_capabilities = None + + async def mock_server(): + nonlocal received_capabilities + + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + received_capabilities = request.root.params.capabilities + + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + async with server_to_client_send: + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + await client_to_server_receive.receive() + + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + ) as session, + anyio.create_task_group() as tg, + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + tg.start_soon(mock_server) + await session.initialize() + + # Assert that tasks capability is None when not provided + assert received_capabilities is not None + assert received_capabilities.tasks is None + + +@pytest.mark.anyio +async def test_client_capabilities_with_tasks(): + """Test that tasks capability is properly set when handlers are provided.""" + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + + received_capabilities: ClientCapabilities | None = None + + # Define custom handlers to trigger capability building (never actually called) + async def my_list_tasks_handler( + context: RequestContext[ClientSession, None], + params: types.PaginatedRequestParams | None, + ) -> types.ListTasksResult | types.ErrorData: + raise NotImplementedError + + async def my_cancel_task_handler( + context: RequestContext[ClientSession, None], + params: types.CancelTaskRequestParams, + ) -> types.CancelTaskResult | types.ErrorData: + raise NotImplementedError + + async def mock_server(): + nonlocal received_capabilities + + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + received_capabilities = request.root.params.capabilities + + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + async with server_to_client_send: + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + await client_to_server_receive.receive() + + # Create handlers container + task_handlers = ExperimentalTaskHandlers( + list_tasks=my_list_tasks_handler, + cancel_task=my_cancel_task_handler, + ) + + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + experimental_task_handlers=task_handlers, + ) as session, + anyio.create_task_group() as tg, + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + tg.start_soon(mock_server) + await session.initialize() + + # Assert that tasks capability is properly set from handlers + assert received_capabilities is not None + assert received_capabilities.tasks is not None + assert isinstance(received_capabilities.tasks, types.ClientTasksCapability) + assert received_capabilities.tasks.list is not None + assert received_capabilities.tasks.cancel is not None + + +@pytest.mark.anyio +async def test_client_capabilities_auto_built_from_handlers(): + """Test that tasks capability is automatically built from provided handlers.""" + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + + received_capabilities: ClientCapabilities | None = None + + # Define custom handlers (not defaults) + async def my_list_tasks_handler( + context: RequestContext[ClientSession, None], + params: types.PaginatedRequestParams | None, + ) -> types.ListTasksResult | types.ErrorData: + raise NotImplementedError + + async def my_cancel_task_handler( + context: RequestContext[ClientSession, None], + params: types.CancelTaskRequestParams, + ) -> types.CancelTaskResult | types.ErrorData: + raise NotImplementedError + + async def mock_server(): + nonlocal received_capabilities + + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + received_capabilities = request.root.params.capabilities + + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + async with server_to_client_send: + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + await client_to_server_receive.receive() + + # Provide handlers via ExperimentalTaskHandlers + task_handlers = ExperimentalTaskHandlers( + list_tasks=my_list_tasks_handler, + cancel_task=my_cancel_task_handler, + ) + + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + experimental_task_handlers=task_handlers, + ) as session, + anyio.create_task_group() as tg, + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + tg.start_soon(mock_server) + await session.initialize() + + # Assert that tasks capability was auto-built from handlers + assert received_capabilities is not None + assert received_capabilities.tasks is not None + assert received_capabilities.tasks.list is not None + assert received_capabilities.tasks.cancel is not None + # requests should be None since we didn't provide task-augmented handlers + assert received_capabilities.tasks.requests is None + + +@pytest.mark.anyio +async def test_client_capabilities_with_task_augmented_handlers(): + """Test that requests capability is built when augmented handlers are provided.""" + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + + received_capabilities: ClientCapabilities | None = None + + # Define task-augmented handler + async def my_augmented_sampling_handler( + context: RequestContext[ClientSession, None], + params: types.CreateMessageRequestParams, + task_metadata: types.TaskMetadata, + ) -> types.CreateTaskResult | types.ErrorData: + raise NotImplementedError + + async def mock_server(): + nonlocal received_capabilities + + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + received_capabilities = request.root.params.capabilities + + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + async with server_to_client_send: + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + await client_to_server_receive.receive() + + # Provide task-augmented sampling handler + task_handlers = ExperimentalTaskHandlers( + augmented_sampling=my_augmented_sampling_handler, + ) + + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + experimental_task_handlers=task_handlers, + ) as session, + anyio.create_task_group() as tg, + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + tg.start_soon(mock_server) + await session.initialize() + + # Assert that tasks capability includes requests.sampling + assert received_capabilities is not None + assert received_capabilities.tasks is not None + assert received_capabilities.tasks.requests is not None + assert received_capabilities.tasks.requests.sampling is not None + assert received_capabilities.tasks.requests.elicitation is None # Not provided diff --git a/tests/experimental/tasks/client/test_handlers.py b/tests/experimental/tasks/client/test_handlers.py new file mode 100644 index 0000000000..86cea42ae1 --- /dev/null +++ b/tests/experimental/tasks/client/test_handlers.py @@ -0,0 +1,878 @@ +"""Tests for client-side task management handlers (server -> client requests). + +These tests verify that clients can handle task-related requests from servers: +- GetTaskRequest - server polling client's task status +- GetTaskPayloadRequest - server getting result from client's task +- ListTasksRequest - server listing client's tasks +- CancelTaskRequest - server cancelling client's task + +This is the inverse of the existing tests in test_tasks.py, which test +client -> server task requests. +""" + +from collections.abc import AsyncIterator +from dataclasses import dataclass + +import anyio +import pytest +from anyio import Event +from anyio.abc import TaskGroup +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream + +import mcp.types as types +from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers +from mcp.client.session import ClientSession +from mcp.shared.context import RequestContext +from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore +from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder +from mcp.types import ( + CancelTaskRequest, + CancelTaskRequestParams, + CancelTaskResult, + ClientResult, + CreateMessageRequest, + CreateMessageRequestParams, + CreateMessageResult, + CreateTaskResult, + ElicitRequest, + ElicitRequestFormParams, + ElicitRequestParams, + ElicitResult, + ErrorData, + GetTaskPayloadRequest, + GetTaskPayloadRequestParams, + GetTaskPayloadResult, + GetTaskRequest, + GetTaskRequestParams, + GetTaskResult, + ListTasksRequest, + ListTasksResult, + SamplingMessage, + ServerNotification, + ServerRequest, + TaskMetadata, + TextContent, +) + +# Buffer size for test streams +STREAM_BUFFER_SIZE = 10 + + +@dataclass +class ClientTestStreams: + """Bidirectional message streams for client/server communication in tests.""" + + server_send: MemoryObjectSendStream[SessionMessage] + server_receive: MemoryObjectReceiveStream[SessionMessage] + client_send: MemoryObjectSendStream[SessionMessage] + client_receive: MemoryObjectReceiveStream[SessionMessage] + + +@pytest.fixture +async def client_streams() -> AsyncIterator[ClientTestStreams]: + """Create bidirectional message streams for client tests. + + Automatically closes all streams after the test completes. + """ + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage]( + STREAM_BUFFER_SIZE + ) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage]( + STREAM_BUFFER_SIZE + ) + + streams = ClientTestStreams( + server_send=server_to_client_send, + server_receive=client_to_server_receive, + client_send=client_to_server_send, + client_receive=server_to_client_receive, + ) + + yield streams + + # Cleanup + await server_to_client_send.aclose() + await server_to_client_receive.aclose() + await client_to_server_send.aclose() + await client_to_server_receive.aclose() + + +async def _default_message_handler( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, +) -> None: + """Default message handler that ignores messages (tests handle them explicitly).""" + ... + + +@pytest.mark.anyio +async def test_client_handles_get_task_request(client_streams: ClientTestStreams) -> None: + """Test that client can respond to GetTaskRequest from server.""" + with anyio.fail_after(10): + store = InMemoryTaskStore() + received_task_id: str | None = None + + async def get_task_handler( + context: RequestContext[ClientSession, None], + params: GetTaskRequestParams, + ) -> GetTaskResult | ErrorData: + nonlocal received_task_id + received_task_id = params.taskId + task = await store.get_task(params.taskId) + assert task is not None, f"Test setup error: task {params.taskId} should exist" + return GetTaskResult( + taskId=task.taskId, + status=task.status, + statusMessage=task.statusMessage, + createdAt=task.createdAt, + lastUpdatedAt=task.lastUpdatedAt, + ttl=task.ttl, + pollInterval=task.pollInterval, + ) + + await store.create_task(TaskMetadata(ttl=60000), task_id="test-task-123") + + task_handlers = ExperimentalTaskHandlers(get_task=get_task_handler) + client_ready = anyio.Event() + + async with anyio.create_task_group() as tg: + + async def run_client() -> None: + async with ClientSession( + client_streams.client_receive, + client_streams.client_send, + message_handler=_default_message_handler, + experimental_task_handlers=task_handlers, + ): + client_ready.set() + await anyio.sleep_forever() + + tg.start_soon(run_client) + await client_ready.wait() + + typed_request = GetTaskRequest(params=GetTaskRequestParams(taskId="test-task-123")) + request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-1", + **typed_request.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + + response_msg = await client_streams.server_receive.receive() + response = response_msg.message.root + assert isinstance(response, types.JSONRPCResponse) + assert response.id == "req-1" + + result = GetTaskResult.model_validate(response.result) + assert result.taskId == "test-task-123" + assert result.status == "working" + assert received_task_id == "test-task-123" + + tg.cancel_scope.cancel() + + store.cleanup() + + +@pytest.mark.anyio +async def test_client_handles_get_task_result_request(client_streams: ClientTestStreams) -> None: + """Test that client can respond to GetTaskPayloadRequest from server.""" + with anyio.fail_after(10): + store = InMemoryTaskStore() + + async def get_task_result_handler( + context: RequestContext[ClientSession, None], + params: GetTaskPayloadRequestParams, + ) -> GetTaskPayloadResult | ErrorData: + result = await store.get_result(params.taskId) + assert result is not None, f"Test setup error: result for {params.taskId} should exist" + assert isinstance(result, types.CallToolResult) + return GetTaskPayloadResult(**result.model_dump()) + + await store.create_task(TaskMetadata(ttl=60000), task_id="test-task-456") + await store.store_result( + "test-task-456", + types.CallToolResult(content=[TextContent(type="text", text="Task completed successfully!")]), + ) + await store.update_task("test-task-456", status="completed") + + task_handlers = ExperimentalTaskHandlers(get_task_result=get_task_result_handler) + client_ready = anyio.Event() + + async with anyio.create_task_group() as tg: + + async def run_client() -> None: + async with ClientSession( + client_streams.client_receive, + client_streams.client_send, + message_handler=_default_message_handler, + experimental_task_handlers=task_handlers, + ): + client_ready.set() + await anyio.sleep_forever() + + tg.start_soon(run_client) + await client_ready.wait() + + typed_request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId="test-task-456")) + request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-2", + **typed_request.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + + response_msg = await client_streams.server_receive.receive() + response = response_msg.message.root + assert isinstance(response, types.JSONRPCResponse) + + assert isinstance(response.result, dict) + result_dict = response.result + assert "content" in result_dict + assert len(result_dict["content"]) == 1 + assert result_dict["content"][0]["text"] == "Task completed successfully!" + + tg.cancel_scope.cancel() + + store.cleanup() + + +@pytest.mark.anyio +async def test_client_handles_list_tasks_request(client_streams: ClientTestStreams) -> None: + """Test that client can respond to ListTasksRequest from server.""" + with anyio.fail_after(10): + store = InMemoryTaskStore() + + async def list_tasks_handler( + context: RequestContext[ClientSession, None], + params: types.PaginatedRequestParams | None, + ) -> ListTasksResult | ErrorData: + cursor = params.cursor if params else None + tasks_list, next_cursor = await store.list_tasks(cursor=cursor) + return ListTasksResult(tasks=tasks_list, nextCursor=next_cursor) + + await store.create_task(TaskMetadata(ttl=60000), task_id="task-1") + await store.create_task(TaskMetadata(ttl=60000), task_id="task-2") + + task_handlers = ExperimentalTaskHandlers(list_tasks=list_tasks_handler) + client_ready = anyio.Event() + + async with anyio.create_task_group() as tg: + + async def run_client() -> None: + async with ClientSession( + client_streams.client_receive, + client_streams.client_send, + message_handler=_default_message_handler, + experimental_task_handlers=task_handlers, + ): + client_ready.set() + await anyio.sleep_forever() + + tg.start_soon(run_client) + await client_ready.wait() + + typed_request = ListTasksRequest() + request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-3", + **typed_request.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + + response_msg = await client_streams.server_receive.receive() + response = response_msg.message.root + assert isinstance(response, types.JSONRPCResponse) + + result = ListTasksResult.model_validate(response.result) + assert len(result.tasks) == 2 + + tg.cancel_scope.cancel() + + store.cleanup() + + +@pytest.mark.anyio +async def test_client_handles_cancel_task_request(client_streams: ClientTestStreams) -> None: + """Test that client can respond to CancelTaskRequest from server.""" + with anyio.fail_after(10): + store = InMemoryTaskStore() + + async def cancel_task_handler( + context: RequestContext[ClientSession, None], + params: CancelTaskRequestParams, + ) -> CancelTaskResult | ErrorData: + task = await store.get_task(params.taskId) + assert task is not None, f"Test setup error: task {params.taskId} should exist" + await store.update_task(params.taskId, status="cancelled") + updated = await store.get_task(params.taskId) + assert updated is not None + return CancelTaskResult( + taskId=updated.taskId, + status=updated.status, + createdAt=updated.createdAt, + lastUpdatedAt=updated.lastUpdatedAt, + ttl=updated.ttl, + ) + + await store.create_task(TaskMetadata(ttl=60000), task_id="task-to-cancel") + + task_handlers = ExperimentalTaskHandlers(cancel_task=cancel_task_handler) + client_ready = anyio.Event() + + async with anyio.create_task_group() as tg: + + async def run_client() -> None: + async with ClientSession( + client_streams.client_receive, + client_streams.client_send, + message_handler=_default_message_handler, + experimental_task_handlers=task_handlers, + ): + client_ready.set() + await anyio.sleep_forever() + + tg.start_soon(run_client) + await client_ready.wait() + + typed_request = CancelTaskRequest(params=CancelTaskRequestParams(taskId="task-to-cancel")) + request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-4", + **typed_request.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + + response_msg = await client_streams.server_receive.receive() + response = response_msg.message.root + assert isinstance(response, types.JSONRPCResponse) + + result = CancelTaskResult.model_validate(response.result) + assert result.taskId == "task-to-cancel" + assert result.status == "cancelled" + + tg.cancel_scope.cancel() + + store.cleanup() + + +@pytest.mark.anyio +async def test_client_task_augmented_sampling(client_streams: ClientTestStreams) -> None: + """Test that client can handle task-augmented sampling request from server.""" + with anyio.fail_after(10): + store = InMemoryTaskStore() + sampling_completed = Event() + created_task_id: list[str | None] = [None] + background_tg: list[TaskGroup | None] = [None] + + async def task_augmented_sampling_callback( + context: RequestContext[ClientSession, None], + params: CreateMessageRequestParams, + task_metadata: TaskMetadata, + ) -> CreateTaskResult: + task = await store.create_task(task_metadata) + created_task_id[0] = task.taskId + + async def do_sampling() -> None: + result = CreateMessageResult( + role="assistant", + content=TextContent(type="text", text="Sampled response"), + model="test-model", + stopReason="endTurn", + ) + await store.store_result(task.taskId, result) + await store.update_task(task.taskId, status="completed") + sampling_completed.set() + + assert background_tg[0] is not None + background_tg[0].start_soon(do_sampling) + return CreateTaskResult(task=task) + + async def get_task_handler( + context: RequestContext[ClientSession, None], + params: GetTaskRequestParams, + ) -> GetTaskResult | ErrorData: + task = await store.get_task(params.taskId) + assert task is not None, f"Test setup error: task {params.taskId} should exist" + return GetTaskResult( + taskId=task.taskId, + status=task.status, + statusMessage=task.statusMessage, + createdAt=task.createdAt, + lastUpdatedAt=task.lastUpdatedAt, + ttl=task.ttl, + pollInterval=task.pollInterval, + ) + + async def get_task_result_handler( + context: RequestContext[ClientSession, None], + params: GetTaskPayloadRequestParams, + ) -> GetTaskPayloadResult | ErrorData: + result = await store.get_result(params.taskId) + assert result is not None, f"Test setup error: result for {params.taskId} should exist" + assert isinstance(result, CreateMessageResult) + return GetTaskPayloadResult(**result.model_dump()) + + task_handlers = ExperimentalTaskHandlers( + augmented_sampling=task_augmented_sampling_callback, + get_task=get_task_handler, + get_task_result=get_task_result_handler, + ) + client_ready = anyio.Event() + + async with anyio.create_task_group() as tg: + background_tg[0] = tg + + async def run_client() -> None: + async with ClientSession( + client_streams.client_receive, + client_streams.client_send, + message_handler=_default_message_handler, + experimental_task_handlers=task_handlers, + ): + client_ready.set() + await anyio.sleep_forever() + + tg.start_soon(run_client) + await client_ready.wait() + + # Step 1: Server sends task-augmented CreateMessageRequest + typed_request = CreateMessageRequest( + params=CreateMessageRequestParams( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))], + maxTokens=100, + task=TaskMetadata(ttl=60000), + ) + ) + request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-sampling", + **typed_request.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + + # Step 2: Client responds with CreateTaskResult + response_msg = await client_streams.server_receive.receive() + response = response_msg.message.root + assert isinstance(response, types.JSONRPCResponse) + + task_result = CreateTaskResult.model_validate(response.result) + task_id = task_result.task.taskId + assert task_id == created_task_id[0] + + # Step 3: Wait for background sampling + await sampling_completed.wait() + + # Step 4: Server polls task status + typed_poll = GetTaskRequest(params=GetTaskRequestParams(taskId=task_id)) + poll_request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-poll", + **typed_poll.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(poll_request))) + + poll_response_msg = await client_streams.server_receive.receive() + poll_response = poll_response_msg.message.root + assert isinstance(poll_response, types.JSONRPCResponse) + + status = GetTaskResult.model_validate(poll_response.result) + assert status.status == "completed" + + # Step 5: Server gets result + typed_result_req = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task_id)) + result_request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-result", + **typed_result_req.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(result_request))) + + result_response_msg = await client_streams.server_receive.receive() + result_response = result_response_msg.message.root + assert isinstance(result_response, types.JSONRPCResponse) + + assert isinstance(result_response.result, dict) + assert result_response.result["role"] == "assistant" + + tg.cancel_scope.cancel() + + store.cleanup() + + +@pytest.mark.anyio +async def test_client_task_augmented_elicitation(client_streams: ClientTestStreams) -> None: + """Test that client can handle task-augmented elicitation request from server.""" + with anyio.fail_after(10): + store = InMemoryTaskStore() + elicitation_completed = Event() + created_task_id: list[str | None] = [None] + background_tg: list[TaskGroup | None] = [None] + + async def task_augmented_elicitation_callback( + context: RequestContext[ClientSession, None], + params: ElicitRequestParams, + task_metadata: TaskMetadata, + ) -> CreateTaskResult | ErrorData: + task = await store.create_task(task_metadata) + created_task_id[0] = task.taskId + + async def do_elicitation() -> None: + # Simulate user providing elicitation response + result = ElicitResult(action="accept", content={"name": "Test User"}) + await store.store_result(task.taskId, result) + await store.update_task(task.taskId, status="completed") + elicitation_completed.set() + + assert background_tg[0] is not None + background_tg[0].start_soon(do_elicitation) + return CreateTaskResult(task=task) + + async def get_task_handler( + context: RequestContext[ClientSession, None], + params: GetTaskRequestParams, + ) -> GetTaskResult | ErrorData: + task = await store.get_task(params.taskId) + assert task is not None, f"Test setup error: task {params.taskId} should exist" + return GetTaskResult( + taskId=task.taskId, + status=task.status, + statusMessage=task.statusMessage, + createdAt=task.createdAt, + lastUpdatedAt=task.lastUpdatedAt, + ttl=task.ttl, + pollInterval=task.pollInterval, + ) + + async def get_task_result_handler( + context: RequestContext[ClientSession, None], + params: GetTaskPayloadRequestParams, + ) -> GetTaskPayloadResult | ErrorData: + result = await store.get_result(params.taskId) + assert result is not None, f"Test setup error: result for {params.taskId} should exist" + assert isinstance(result, ElicitResult) + return GetTaskPayloadResult(**result.model_dump()) + + task_handlers = ExperimentalTaskHandlers( + augmented_elicitation=task_augmented_elicitation_callback, + get_task=get_task_handler, + get_task_result=get_task_result_handler, + ) + client_ready = anyio.Event() + + async with anyio.create_task_group() as tg: + background_tg[0] = tg + + async def run_client() -> None: + async with ClientSession( + client_streams.client_receive, + client_streams.client_send, + message_handler=_default_message_handler, + experimental_task_handlers=task_handlers, + ): + client_ready.set() + await anyio.sleep_forever() + + tg.start_soon(run_client) + await client_ready.wait() + + # Step 1: Server sends task-augmented ElicitRequest + typed_request = ElicitRequest( + params=ElicitRequestFormParams( + message="What is your name?", + requestedSchema={"type": "object", "properties": {"name": {"type": "string"}}}, + task=TaskMetadata(ttl=60000), + ) + ) + request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-elicit", + **typed_request.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + + # Step 2: Client responds with CreateTaskResult + response_msg = await client_streams.server_receive.receive() + response = response_msg.message.root + assert isinstance(response, types.JSONRPCResponse) + + task_result = CreateTaskResult.model_validate(response.result) + task_id = task_result.task.taskId + assert task_id == created_task_id[0] + + # Step 3: Wait for background elicitation + await elicitation_completed.wait() + + # Step 4: Server polls task status + typed_poll = GetTaskRequest(params=GetTaskRequestParams(taskId=task_id)) + poll_request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-poll", + **typed_poll.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(poll_request))) + + poll_response_msg = await client_streams.server_receive.receive() + poll_response = poll_response_msg.message.root + assert isinstance(poll_response, types.JSONRPCResponse) + + status = GetTaskResult.model_validate(poll_response.result) + assert status.status == "completed" + + # Step 5: Server gets result + typed_result_req = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task_id)) + result_request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-result", + **typed_result_req.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(result_request))) + + result_response_msg = await client_streams.server_receive.receive() + result_response = result_response_msg.message.root + assert isinstance(result_response, types.JSONRPCResponse) + + # Verify the elicitation result + assert isinstance(result_response.result, dict) + assert result_response.result["action"] == "accept" + assert result_response.result["content"] == {"name": "Test User"} + + tg.cancel_scope.cancel() + + store.cleanup() + + +@pytest.mark.anyio +async def test_client_returns_error_for_unhandled_task_request(client_streams: ClientTestStreams) -> None: + """Test that client returns error when no handler is registered for task request.""" + with anyio.fail_after(10): + client_ready = anyio.Event() + + async with anyio.create_task_group() as tg: + + async def run_client() -> None: + async with ClientSession( + client_streams.client_receive, + client_streams.client_send, + message_handler=_default_message_handler, + ): + client_ready.set() + await anyio.sleep_forever() + + tg.start_soon(run_client) + await client_ready.wait() + + typed_request = GetTaskRequest(params=GetTaskRequestParams(taskId="nonexistent")) + request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-unhandled", + **typed_request.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + + response_msg = await client_streams.server_receive.receive() + response = response_msg.message.root + assert isinstance(response, types.JSONRPCError) + assert ( + "not supported" in response.error.message.lower() + or "method not found" in response.error.message.lower() + ) + + tg.cancel_scope.cancel() + + +@pytest.mark.anyio +async def test_client_returns_error_for_unhandled_task_result_request(client_streams: ClientTestStreams) -> None: + """Test that client returns error for unhandled tasks/result request.""" + with anyio.fail_after(10): + client_ready = anyio.Event() + + async with anyio.create_task_group() as tg: + + async def run_client() -> None: + async with ClientSession( + client_streams.client_receive, + client_streams.client_send, + message_handler=_default_message_handler, + ): + client_ready.set() + await anyio.sleep_forever() + + tg.start_soon(run_client) + await client_ready.wait() + + typed_request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId="nonexistent")) + request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-result", + **typed_request.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + + response_msg = await client_streams.server_receive.receive() + response = response_msg.message.root + assert isinstance(response, types.JSONRPCError) + assert "not supported" in response.error.message.lower() + + tg.cancel_scope.cancel() + + +@pytest.mark.anyio +async def test_client_returns_error_for_unhandled_list_tasks_request(client_streams: ClientTestStreams) -> None: + """Test that client returns error for unhandled tasks/list request.""" + with anyio.fail_after(10): + client_ready = anyio.Event() + + async with anyio.create_task_group() as tg: + + async def run_client() -> None: + async with ClientSession( + client_streams.client_receive, + client_streams.client_send, + message_handler=_default_message_handler, + ): + client_ready.set() + await anyio.sleep_forever() + + tg.start_soon(run_client) + await client_ready.wait() + + typed_request = ListTasksRequest() + request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-list", + **typed_request.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + + response_msg = await client_streams.server_receive.receive() + response = response_msg.message.root + assert isinstance(response, types.JSONRPCError) + assert "not supported" in response.error.message.lower() + + tg.cancel_scope.cancel() + + +@pytest.mark.anyio +async def test_client_returns_error_for_unhandled_cancel_task_request(client_streams: ClientTestStreams) -> None: + """Test that client returns error for unhandled tasks/cancel request.""" + with anyio.fail_after(10): + client_ready = anyio.Event() + + async with anyio.create_task_group() as tg: + + async def run_client() -> None: + async with ClientSession( + client_streams.client_receive, + client_streams.client_send, + message_handler=_default_message_handler, + ): + client_ready.set() + await anyio.sleep_forever() + + tg.start_soon(run_client) + await client_ready.wait() + + typed_request = CancelTaskRequest(params=CancelTaskRequestParams(taskId="nonexistent")) + request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-cancel", + **typed_request.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + + response_msg = await client_streams.server_receive.receive() + response = response_msg.message.root + assert isinstance(response, types.JSONRPCError) + assert "not supported" in response.error.message.lower() + + tg.cancel_scope.cancel() + + +@pytest.mark.anyio +async def test_client_returns_error_for_unhandled_task_augmented_sampling(client_streams: ClientTestStreams) -> None: + """Test that client returns error for task-augmented sampling without handler.""" + with anyio.fail_after(10): + client_ready = anyio.Event() + + async with anyio.create_task_group() as tg: + + async def run_client() -> None: + # No task handlers provided - uses defaults + async with ClientSession( + client_streams.client_receive, + client_streams.client_send, + message_handler=_default_message_handler, + ): + client_ready.set() + await anyio.sleep_forever() + + tg.start_soon(run_client) + await client_ready.wait() + + # Send task-augmented sampling request + typed_request = CreateMessageRequest( + params=CreateMessageRequestParams( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))], + maxTokens=100, + task=TaskMetadata(ttl=60000), + ) + ) + request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-sampling", + **typed_request.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + + response_msg = await client_streams.server_receive.receive() + response = response_msg.message.root + assert isinstance(response, types.JSONRPCError) + assert "not supported" in response.error.message.lower() + + tg.cancel_scope.cancel() + + +@pytest.mark.anyio +async def test_client_returns_error_for_unhandled_task_augmented_elicitation( + client_streams: ClientTestStreams, +) -> None: + """Test that client returns error for task-augmented elicitation without handler.""" + with anyio.fail_after(10): + client_ready = anyio.Event() + + async with anyio.create_task_group() as tg: + + async def run_client() -> None: + # No task handlers provided - uses defaults + async with ClientSession( + client_streams.client_receive, + client_streams.client_send, + message_handler=_default_message_handler, + ): + client_ready.set() + await anyio.sleep_forever() + + tg.start_soon(run_client) + await client_ready.wait() + + # Send task-augmented elicitation request + typed_request = ElicitRequest( + params=ElicitRequestFormParams( + message="What is your name?", + requestedSchema={"type": "object", "properties": {"name": {"type": "string"}}}, + task=TaskMetadata(ttl=60000), + ) + ) + request = types.JSONRPCRequest( + jsonrpc="2.0", + id="req-elicit", + **typed_request.model_dump(by_alias=True), + ) + await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + + response_msg = await client_streams.server_receive.receive() + response = response_msg.message.root + assert isinstance(response, types.JSONRPCError) + assert "not supported" in response.error.message.lower() + + tg.cancel_scope.cancel() diff --git a/tests/experimental/tasks/client/test_poll_task.py b/tests/experimental/tasks/client/test_poll_task.py new file mode 100644 index 0000000000..8275dc668e --- /dev/null +++ b/tests/experimental/tasks/client/test_poll_task.py @@ -0,0 +1,121 @@ +"""Tests for poll_task async iterator.""" + +from collections.abc import Callable, Coroutine +from datetime import datetime, timezone +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from mcp.client.experimental.tasks import ExperimentalClientFeatures +from mcp.types import GetTaskResult, TaskStatus + + +def make_task_result( + status: TaskStatus = "working", + poll_interval: int = 0, + task_id: str = "test-task", + status_message: str | None = None, +) -> GetTaskResult: + """Create GetTaskResult with sensible defaults.""" + now = datetime.now(timezone.utc) + return GetTaskResult( + taskId=task_id, + status=status, + statusMessage=status_message, + createdAt=now, + lastUpdatedAt=now, + ttl=60000, + pollInterval=poll_interval, + ) + + +def make_status_sequence( + *statuses: TaskStatus, + task_id: str = "test-task", +) -> Callable[[str], Coroutine[Any, Any, GetTaskResult]]: + """Create mock get_task that returns statuses in sequence.""" + status_iter = iter(statuses) + + async def mock_get_task(tid: str) -> GetTaskResult: + return make_task_result(status=next(status_iter), task_id=tid) + + return mock_get_task + + +@pytest.fixture +def mock_session() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def features(mock_session: AsyncMock) -> ExperimentalClientFeatures: + return ExperimentalClientFeatures(mock_session) + + +@pytest.mark.anyio +async def test_poll_task_yields_until_completed(features: ExperimentalClientFeatures) -> None: + """poll_task yields each status until terminal.""" + features.get_task = make_status_sequence("working", "working", "completed") # type: ignore[method-assign] + + statuses = [s.status async for s in features.poll_task("test-task")] + + assert statuses == ["working", "working", "completed"] + + +@pytest.mark.anyio +@pytest.mark.parametrize("terminal_status", ["completed", "failed", "cancelled"]) +async def test_poll_task_exits_on_terminal(features: ExperimentalClientFeatures, terminal_status: TaskStatus) -> None: + """poll_task exits immediately when task is already terminal.""" + features.get_task = make_status_sequence(terminal_status) # type: ignore[method-assign] + + statuses = [s.status async for s in features.poll_task("test-task")] + + assert statuses == [terminal_status] + + +@pytest.mark.anyio +async def test_poll_task_continues_through_input_required(features: ExperimentalClientFeatures) -> None: + """poll_task yields input_required and continues (non-terminal).""" + features.get_task = make_status_sequence("working", "input_required", "working", "completed") # type: ignore[method-assign] + + statuses = [s.status async for s in features.poll_task("test-task")] + + assert statuses == ["working", "input_required", "working", "completed"] + + +@pytest.mark.anyio +async def test_poll_task_passes_task_id(features: ExperimentalClientFeatures) -> None: + """poll_task passes correct task_id to get_task.""" + received_ids: list[str] = [] + + async def mock_get_task(task_id: str) -> GetTaskResult: + received_ids.append(task_id) + return make_task_result(status="completed", task_id=task_id) + + features.get_task = mock_get_task # type: ignore[method-assign] + + _ = [s async for s in features.poll_task("my-task-123")] + + assert received_ids == ["my-task-123"] + + +@pytest.mark.anyio +async def test_poll_task_yields_full_result(features: ExperimentalClientFeatures) -> None: + """poll_task yields complete GetTaskResult objects.""" + + async def mock_get_task(task_id: str) -> GetTaskResult: + return make_task_result( + status="completed", + task_id=task_id, + status_message="All done!", + ) + + features.get_task = mock_get_task # type: ignore[method-assign] + + results = [r async for r in features.poll_task("test-task")] + + assert len(results) == 1 + assert results[0].status == "completed" + assert results[0].statusMessage == "All done!" + assert results[0].taskId == "test-task" diff --git a/tests/experimental/tasks/client/test_tasks.py b/tests/experimental/tasks/client/test_tasks.py new file mode 100644 index 0000000000..24c8891def --- /dev/null +++ b/tests/experimental/tasks/client/test_tasks.py @@ -0,0 +1,483 @@ +"""Tests for the experimental client task methods (session.experimental).""" + +from dataclasses import dataclass, field +from typing import Any + +import anyio +import pytest +from anyio import Event +from anyio.abc import TaskGroup + +from mcp.client.session import ClientSession +from mcp.server import Server +from mcp.server.lowlevel import NotificationOptions +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.experimental.tasks.helpers import task_execution +from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore +from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder +from mcp.types import ( + CallToolRequest, + CallToolRequestParams, + CallToolResult, + CancelTaskRequest, + CancelTaskResult, + ClientRequest, + ClientResult, + CreateTaskResult, + GetTaskPayloadRequest, + GetTaskPayloadResult, + GetTaskRequest, + GetTaskResult, + ListTasksRequest, + ListTasksResult, + ServerNotification, + ServerRequest, + TaskMetadata, + TextContent, + Tool, +) + + +@dataclass +class AppContext: + """Application context passed via lifespan_context.""" + + task_group: TaskGroup + store: InMemoryTaskStore + task_done_events: dict[str, Event] = field(default_factory=lambda: {}) + + +@pytest.mark.anyio +async def test_session_experimental_get_task() -> None: + """Test session.experimental.get_task() method.""" + # Note: We bypass the normal lifespan mechanism + server: Server[AppContext, Any] = Server("test-server") # type: ignore[assignment] + store = InMemoryTaskStore() + + @server.list_tools() + async def list_tools(): + return [Tool(name="test_tool", description="Test", inputSchema={"type": "object"})] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult: + ctx = server.request_context + app = ctx.lifespan_context + if ctx.experimental.is_task: + task_metadata = ctx.experimental.task_metadata + assert task_metadata is not None + task = await app.store.create_task(task_metadata) + + done_event = Event() + app.task_done_events[task.taskId] = done_event + + async def do_work(): + async with task_execution(task.taskId, app.store) as task_ctx: + await task_ctx.complete(CallToolResult(content=[TextContent(type="text", text="Done")])) + done_event.set() + + app.task_group.start_soon(do_work) + return CreateTaskResult(task=task) + + raise NotImplementedError + + @server.experimental.get_task() + async def handle_get_task(request: GetTaskRequest) -> GetTaskResult: + app = server.request_context.lifespan_context + task = await app.store.get_task(request.params.taskId) + assert task is not None, f"Test setup error: task {request.params.taskId} should exist" + return GetTaskResult( + taskId=task.taskId, + status=task.status, + statusMessage=task.statusMessage, + createdAt=task.createdAt, + lastUpdatedAt=task.lastUpdatedAt, + ttl=task.ttl, + pollInterval=task.pollInterval, + ) + + # Set up streams + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def message_handler( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: ... # pragma: no branch + + async def run_server(app_context: AppContext): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) as server_session: + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, app_context, raise_exceptions=False) + + async with anyio.create_task_group() as tg: + app_context = AppContext(task_group=tg, store=store) + tg.start_soon(run_server, app_context) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=message_handler, + ) as client_session: + await client_session.initialize() + + # Create a task + create_result = await client_session.send_request( + ClientRequest( + CallToolRequest( + params=CallToolRequestParams( + name="test_tool", + arguments={}, + task=TaskMetadata(ttl=60000), + ) + ) + ), + CreateTaskResult, + ) + task_id = create_result.task.taskId + + # Wait for task to complete + await app_context.task_done_events[task_id].wait() + + # Use session.experimental to get task status + task_status = await client_session.experimental.get_task(task_id) + + assert task_status.taskId == task_id + assert task_status.status == "completed" + + tg.cancel_scope.cancel() + + +@pytest.mark.anyio +async def test_session_experimental_get_task_result() -> None: + """Test session.experimental.get_task_result() method.""" + server: Server[AppContext, Any] = Server("test-server") # type: ignore[assignment] + store = InMemoryTaskStore() + + @server.list_tools() + async def list_tools(): + return [Tool(name="test_tool", description="Test", inputSchema={"type": "object"})] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult: + ctx = server.request_context + app = ctx.lifespan_context + if ctx.experimental.is_task: + task_metadata = ctx.experimental.task_metadata + assert task_metadata is not None + task = await app.store.create_task(task_metadata) + + done_event = Event() + app.task_done_events[task.taskId] = done_event + + async def do_work(): + async with task_execution(task.taskId, app.store) as task_ctx: + await task_ctx.complete( + CallToolResult(content=[TextContent(type="text", text="Task result content")]) + ) + done_event.set() + + app.task_group.start_soon(do_work) + return CreateTaskResult(task=task) + + raise NotImplementedError + + @server.experimental.get_task_result() + async def handle_get_task_result( + request: GetTaskPayloadRequest, + ) -> GetTaskPayloadResult: + app = server.request_context.lifespan_context + result = await app.store.get_result(request.params.taskId) + assert result is not None, f"Test setup error: result for {request.params.taskId} should exist" + assert isinstance(result, CallToolResult) + return GetTaskPayloadResult(**result.model_dump()) + + # Set up streams + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def message_handler( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: ... # pragma: no branch + + async def run_server(app_context: AppContext): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) as server_session: + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, app_context, raise_exceptions=False) + + async with anyio.create_task_group() as tg: + app_context = AppContext(task_group=tg, store=store) + tg.start_soon(run_server, app_context) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=message_handler, + ) as client_session: + await client_session.initialize() + + # Create a task + create_result = await client_session.send_request( + ClientRequest( + CallToolRequest( + params=CallToolRequestParams( + name="test_tool", + arguments={}, + task=TaskMetadata(ttl=60000), + ) + ) + ), + CreateTaskResult, + ) + task_id = create_result.task.taskId + + # Wait for task to complete + await app_context.task_done_events[task_id].wait() + + # Use TaskClient to get task result + task_result = await client_session.experimental.get_task_result(task_id, CallToolResult) + + assert len(task_result.content) == 1 + content = task_result.content[0] + assert isinstance(content, TextContent) + assert content.text == "Task result content" + + tg.cancel_scope.cancel() + + +@pytest.mark.anyio +async def test_session_experimental_list_tasks() -> None: + """Test TaskClient.list_tasks() method.""" + server: Server[AppContext, Any] = Server("test-server") # type: ignore[assignment] + store = InMemoryTaskStore() + + @server.list_tools() + async def list_tools(): + return [Tool(name="test_tool", description="Test", inputSchema={"type": "object"})] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult: + ctx = server.request_context + app = ctx.lifespan_context + if ctx.experimental.is_task: + task_metadata = ctx.experimental.task_metadata + assert task_metadata is not None + task = await app.store.create_task(task_metadata) + + done_event = Event() + app.task_done_events[task.taskId] = done_event + + async def do_work(): + async with task_execution(task.taskId, app.store) as task_ctx: + await task_ctx.complete(CallToolResult(content=[TextContent(type="text", text="Done")])) + done_event.set() + + app.task_group.start_soon(do_work) + return CreateTaskResult(task=task) + + raise NotImplementedError + + @server.experimental.list_tasks() + async def handle_list_tasks(request: ListTasksRequest) -> ListTasksResult: + app = server.request_context.lifespan_context + tasks_list, next_cursor = await app.store.list_tasks(cursor=request.params.cursor if request.params else None) + return ListTasksResult(tasks=tasks_list, nextCursor=next_cursor) + + # Set up streams + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def message_handler( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: ... # pragma: no branch + + async def run_server(app_context: AppContext): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) as server_session: + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, app_context, raise_exceptions=False) + + async with anyio.create_task_group() as tg: + app_context = AppContext(task_group=tg, store=store) + tg.start_soon(run_server, app_context) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=message_handler, + ) as client_session: + await client_session.initialize() + + # Create two tasks + for _ in range(2): + create_result = await client_session.send_request( + ClientRequest( + CallToolRequest( + params=CallToolRequestParams( + name="test_tool", + arguments={}, + task=TaskMetadata(ttl=60000), + ) + ) + ), + CreateTaskResult, + ) + await app_context.task_done_events[create_result.task.taskId].wait() + + # Use TaskClient to list tasks + list_result = await client_session.experimental.list_tasks() + + assert len(list_result.tasks) == 2 + + tg.cancel_scope.cancel() + + +@pytest.mark.anyio +async def test_session_experimental_cancel_task() -> None: + """Test TaskClient.cancel_task() method.""" + server: Server[AppContext, Any] = Server("test-server") # type: ignore[assignment] + store = InMemoryTaskStore() + + @server.list_tools() + async def list_tools(): + return [Tool(name="test_tool", description="Test", inputSchema={"type": "object"})] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult: + ctx = server.request_context + app = ctx.lifespan_context + if ctx.experimental.is_task: + task_metadata = ctx.experimental.task_metadata + assert task_metadata is not None + task = await app.store.create_task(task_metadata) + # Don't start any work - task stays in "working" status + return CreateTaskResult(task=task) + + raise NotImplementedError + + @server.experimental.get_task() + async def handle_get_task(request: GetTaskRequest) -> GetTaskResult: + app = server.request_context.lifespan_context + task = await app.store.get_task(request.params.taskId) + assert task is not None, f"Test setup error: task {request.params.taskId} should exist" + return GetTaskResult( + taskId=task.taskId, + status=task.status, + statusMessage=task.statusMessage, + createdAt=task.createdAt, + lastUpdatedAt=task.lastUpdatedAt, + ttl=task.ttl, + pollInterval=task.pollInterval, + ) + + @server.experimental.cancel_task() + async def handle_cancel_task(request: CancelTaskRequest) -> CancelTaskResult: + app = server.request_context.lifespan_context + task = await app.store.get_task(request.params.taskId) + assert task is not None, f"Test setup error: task {request.params.taskId} should exist" + await app.store.update_task(request.params.taskId, status="cancelled") + # CancelTaskResult extends Task, so we need to return the updated task info + updated_task = await app.store.get_task(request.params.taskId) + assert updated_task is not None + return CancelTaskResult( + taskId=updated_task.taskId, + status=updated_task.status, + createdAt=updated_task.createdAt, + lastUpdatedAt=updated_task.lastUpdatedAt, + ttl=updated_task.ttl, + ) + + # Set up streams + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def message_handler( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: ... # pragma: no branch + + async def run_server(app_context: AppContext): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) as server_session: + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, app_context, raise_exceptions=False) + + async with anyio.create_task_group() as tg: + app_context = AppContext(task_group=tg, store=store) + tg.start_soon(run_server, app_context) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=message_handler, + ) as client_session: + await client_session.initialize() + + # Create a task (but don't complete it) + create_result = await client_session.send_request( + ClientRequest( + CallToolRequest( + params=CallToolRequestParams( + name="test_tool", + arguments={}, + task=TaskMetadata(ttl=60000), + ) + ) + ), + CreateTaskResult, + ) + task_id = create_result.task.taskId + + # Verify task is working + status_before = await client_session.experimental.get_task(task_id) + assert status_before.status == "working" + + # Cancel the task + await client_session.experimental.cancel_task(task_id) + + # Verify task is cancelled + status_after = await client_session.experimental.get_task(task_id) + assert status_after.status == "cancelled" + + tg.cancel_scope.cancel() diff --git a/tests/experimental/tasks/server/__init__.py b/tests/experimental/tasks/server/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/experimental/tasks/server/test_context.py b/tests/experimental/tasks/server/test_context.py new file mode 100644 index 0000000000..2f09ff1540 --- /dev/null +++ b/tests/experimental/tasks/server/test_context.py @@ -0,0 +1,183 @@ +"""Tests for TaskContext and helper functions.""" + +import pytest + +from mcp.shared.experimental.tasks.context import TaskContext +from mcp.shared.experimental.tasks.helpers import create_task_state, task_execution +from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore +from mcp.types import CallToolResult, TaskMetadata, TextContent + + +@pytest.mark.anyio +async def test_task_context_properties() -> None: + """Test TaskContext basic properties.""" + store = InMemoryTaskStore() + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + ctx = TaskContext(task, store) + + assert ctx.task_id == task.taskId + assert ctx.task.taskId == task.taskId + assert ctx.task.status == "working" + assert ctx.is_cancelled is False + + store.cleanup() + + +@pytest.mark.anyio +async def test_task_context_update_status() -> None: + """Test TaskContext.update_status.""" + store = InMemoryTaskStore() + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + ctx = TaskContext(task, store) + + await ctx.update_status("Processing step 1...") + + # Check status message was updated + updated = await store.get_task(task.taskId) + assert updated is not None + assert updated.statusMessage == "Processing step 1..." + + store.cleanup() + + +@pytest.mark.anyio +async def test_task_context_complete() -> None: + """Test TaskContext.complete.""" + store = InMemoryTaskStore() + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + ctx = TaskContext(task, store) + + result = CallToolResult(content=[TextContent(type="text", text="Done!")]) + await ctx.complete(result) + + # Check task status + updated = await store.get_task(task.taskId) + assert updated is not None + assert updated.status == "completed" + + # Check result is stored + stored_result = await store.get_result(task.taskId) + assert stored_result is not None + + store.cleanup() + + +@pytest.mark.anyio +async def test_task_context_fail() -> None: + """Test TaskContext.fail.""" + store = InMemoryTaskStore() + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + ctx = TaskContext(task, store) + + await ctx.fail("Something went wrong!") + + # Check task status + updated = await store.get_task(task.taskId) + assert updated is not None + assert updated.status == "failed" + assert updated.statusMessage == "Something went wrong!" + + store.cleanup() + + +@pytest.mark.anyio +async def test_task_context_cancellation() -> None: + """Test TaskContext cancellation request.""" + store = InMemoryTaskStore() + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + ctx = TaskContext(task, store) + + assert ctx.is_cancelled is False + + ctx.request_cancellation() + + assert ctx.is_cancelled is True + + store.cleanup() + + +def test_create_task_state_generates_id() -> None: + """create_task_state generates a unique task ID when none provided.""" + task1 = create_task_state(TaskMetadata(ttl=60000)) + task2 = create_task_state(TaskMetadata(ttl=60000)) + + assert task1.taskId != task2.taskId + + +def test_create_task_state_uses_provided_id() -> None: + """create_task_state uses the provided task ID.""" + task = create_task_state(TaskMetadata(ttl=60000), task_id="my-task-123") + assert task.taskId == "my-task-123" + + +def test_create_task_state_null_ttl() -> None: + """create_task_state handles null TTL.""" + task = create_task_state(TaskMetadata(ttl=None)) + assert task.ttl is None + + +def test_create_task_state_has_created_at() -> None: + """create_task_state sets createdAt timestamp.""" + task = create_task_state(TaskMetadata(ttl=60000)) + assert task.createdAt is not None + + +@pytest.mark.anyio +async def test_task_execution_provides_context() -> None: + """task_execution provides a TaskContext for the task.""" + store = InMemoryTaskStore() + await store.create_task(TaskMetadata(ttl=60000), task_id="exec-test-1") + + async with task_execution("exec-test-1", store) as ctx: + assert ctx.task_id == "exec-test-1" + assert ctx.task.status == "working" + + store.cleanup() + + +@pytest.mark.anyio +async def test_task_execution_auto_fails_on_exception() -> None: + """task_execution automatically fails task on unhandled exception.""" + store = InMemoryTaskStore() + await store.create_task(TaskMetadata(ttl=60000), task_id="exec-fail-1") + + async with task_execution("exec-fail-1", store): + raise RuntimeError("Oops!") + + # Task should be failed + failed_task = await store.get_task("exec-fail-1") + assert failed_task is not None + assert failed_task.status == "failed" + assert "Oops!" in (failed_task.statusMessage or "") + + store.cleanup() + + +@pytest.mark.anyio +async def test_task_execution_doesnt_fail_if_already_terminal() -> None: + """task_execution doesn't re-fail if task already terminal.""" + store = InMemoryTaskStore() + await store.create_task(TaskMetadata(ttl=60000), task_id="exec-term-1") + + async with task_execution("exec-term-1", store) as ctx: + # Complete the task first + await ctx.complete(CallToolResult(content=[TextContent(type="text", text="Done")])) + # Then raise - shouldn't change status + raise RuntimeError("This shouldn't matter") + + # Task should remain completed + final_task = await store.get_task("exec-term-1") + assert final_task is not None + assert final_task.status == "completed" + + store.cleanup() + + +@pytest.mark.anyio +async def test_task_execution_not_found() -> None: + """task_execution raises ValueError for non-existent task.""" + store = InMemoryTaskStore() + + with pytest.raises(ValueError, match="not found"): + async with task_execution("nonexistent", store): + ... diff --git a/tests/experimental/tasks/server/test_integration.py b/tests/experimental/tasks/server/test_integration.py new file mode 100644 index 0000000000..ba61dfcead --- /dev/null +++ b/tests/experimental/tasks/server/test_integration.py @@ -0,0 +1,357 @@ +"""End-to-end integration tests for tasks functionality. + +These tests demonstrate the full task lifecycle: +1. Client sends task-augmented request (tools/call with task metadata) +2. Server creates task and returns CreateTaskResult immediately +3. Background work executes (using task_execution context manager) +4. Client polls with tasks/get +5. Client retrieves result with tasks/result +""" + +from dataclasses import dataclass, field +from typing import Any + +import anyio +import pytest +from anyio import Event +from anyio.abc import TaskGroup + +from mcp.client.session import ClientSession +from mcp.server import Server +from mcp.server.lowlevel import NotificationOptions +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.experimental.tasks.helpers import task_execution +from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore +from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder +from mcp.types import ( + TASK_REQUIRED, + CallToolRequest, + CallToolRequestParams, + CallToolResult, + ClientRequest, + ClientResult, + CreateTaskResult, + GetTaskPayloadRequest, + GetTaskPayloadRequestParams, + GetTaskPayloadResult, + GetTaskRequest, + GetTaskRequestParams, + GetTaskResult, + ListTasksRequest, + ListTasksResult, + ServerNotification, + ServerRequest, + TaskMetadata, + TextContent, + Tool, + ToolExecution, +) + + +@dataclass +class AppContext: + """Application context passed via lifespan_context.""" + + task_group: TaskGroup + store: InMemoryTaskStore + # Events to signal when tasks complete (for testing without sleeps) + task_done_events: dict[str, Event] = field(default_factory=lambda: {}) + + +@pytest.mark.anyio +async def test_task_lifecycle_with_task_execution() -> None: + """ + Test the complete task lifecycle using the task_execution pattern. + + This demonstrates the recommended way to implement task-augmented tools: + 1. Create task in store + 2. Spawn work using task_execution() context manager + 3. Return CreateTaskResult immediately + 4. Work executes in background, auto-fails on exception + """ + # Note: We bypass the normal lifespan mechanism and pass context directly to _handle_message + server: Server[AppContext, Any] = Server("test-tasks") # type: ignore[assignment] + store = InMemoryTaskStore() + + @server.list_tools() + async def list_tools(): + return [ + Tool( + name="process_data", + description="Process data asynchronously", + inputSchema={ + "type": "object", + "properties": {"input": {"type": "string"}}, + }, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult: + ctx = server.request_context + app = ctx.lifespan_context + if name == "process_data" and ctx.experimental.is_task: + # 1. Create task in store + task_metadata = ctx.experimental.task_metadata + assert task_metadata is not None + task = await app.store.create_task(task_metadata) + + # 2. Create event to signal completion (for testing) + done_event = Event() + app.task_done_events[task.taskId] = done_event + + # 3. Define work function using task_execution for safety + async def do_work(): + async with task_execution(task.taskId, app.store) as task_ctx: + await task_ctx.update_status("Processing input...") + # Simulate work + input_value = arguments.get("input", "") + result_text = f"Processed: {input_value.upper()}" + await task_ctx.complete(CallToolResult(content=[TextContent(type="text", text=result_text)])) + # Signal completion + done_event.set() + + # 4. Spawn work in task group (from lifespan_context) + app.task_group.start_soon(do_work) + + # 5. Return CreateTaskResult immediately + return CreateTaskResult(task=task) + + raise NotImplementedError + + # Register task query handlers (delegate to store) + @server.experimental.get_task() + async def handle_get_task(request: GetTaskRequest) -> GetTaskResult: + app = server.request_context.lifespan_context + task = await app.store.get_task(request.params.taskId) + assert task is not None, f"Test setup error: task {request.params.taskId} should exist" + return GetTaskResult( + taskId=task.taskId, + status=task.status, + statusMessage=task.statusMessage, + createdAt=task.createdAt, + lastUpdatedAt=task.lastUpdatedAt, + ttl=task.ttl, + pollInterval=task.pollInterval, + ) + + @server.experimental.get_task_result() + async def handle_get_task_result( + request: GetTaskPayloadRequest, + ) -> GetTaskPayloadResult: + app = server.request_context.lifespan_context + result = await app.store.get_result(request.params.taskId) + assert result is not None, f"Test setup error: result for {request.params.taskId} should exist" + assert isinstance(result, CallToolResult) + # Return as GetTaskPayloadResult (which accepts extra fields) + return GetTaskPayloadResult(**result.model_dump()) + + @server.experimental.list_tasks() + async def handle_list_tasks(request: ListTasksRequest) -> ListTasksResult: + raise NotImplementedError + + # Set up client-server communication + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def message_handler( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: ... # pragma: no cover + + async def run_server(app_context: AppContext): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) as server_session: + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, app_context, raise_exceptions=False) + + async with anyio.create_task_group() as tg: + # Create app context with task group and store + app_context = AppContext(task_group=tg, store=store) + tg.start_soon(run_server, app_context) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=message_handler, + ) as client_session: + await client_session.initialize() + + # === Step 1: Send task-augmented tool call === + create_result = await client_session.send_request( + ClientRequest( + CallToolRequest( + params=CallToolRequestParams( + name="process_data", + arguments={"input": "hello world"}, + task=TaskMetadata(ttl=60000), + ), + ) + ), + CreateTaskResult, + ) + + assert isinstance(create_result, CreateTaskResult) + assert create_result.task.status == "working" + task_id = create_result.task.taskId + + # === Step 2: Wait for task to complete === + await app_context.task_done_events[task_id].wait() + + task_status = await client_session.send_request( + ClientRequest(GetTaskRequest(params=GetTaskRequestParams(taskId=task_id))), + GetTaskResult, + ) + + assert task_status.taskId == task_id + assert task_status.status == "completed" + + # === Step 3: Retrieve the actual result === + task_result = await client_session.send_request( + ClientRequest(GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task_id))), + CallToolResult, + ) + + assert len(task_result.content) == 1 + content = task_result.content[0] + assert isinstance(content, TextContent) + assert content.text == "Processed: HELLO WORLD" + + tg.cancel_scope.cancel() + + +@pytest.mark.anyio +async def test_task_auto_fails_on_exception() -> None: + """Test that task_execution automatically fails the task on unhandled exception.""" + # Note: We bypass the normal lifespan mechanism and pass context directly to _handle_message + server: Server[AppContext, Any] = Server("test-tasks-failure") # type: ignore[assignment] + store = InMemoryTaskStore() + + @server.list_tools() + async def list_tools(): + return [ + Tool( + name="failing_task", + description="A task that fails", + inputSchema={"type": "object", "properties": {}}, + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult: + ctx = server.request_context + app = ctx.lifespan_context + if name == "failing_task" and ctx.experimental.is_task: + task_metadata = ctx.experimental.task_metadata + assert task_metadata is not None + task = await app.store.create_task(task_metadata) + + # Create event to signal completion (for testing) + done_event = Event() + app.task_done_events[task.taskId] = done_event + + async def do_failing_work(): + async with task_execution(task.taskId, app.store) as task_ctx: + await task_ctx.update_status("About to fail...") + raise RuntimeError("Something went wrong!") + # Note: complete() is never called, but task_execution + # will automatically call fail() due to the exception + # This line is reached because task_execution suppresses the exception + done_event.set() + + app.task_group.start_soon(do_failing_work) + return CreateTaskResult(task=task) + + raise NotImplementedError + + @server.experimental.get_task() + async def handle_get_task(request: GetTaskRequest) -> GetTaskResult: + app = server.request_context.lifespan_context + task = await app.store.get_task(request.params.taskId) + assert task is not None, f"Test setup error: task {request.params.taskId} should exist" + return GetTaskResult( + taskId=task.taskId, + status=task.status, + statusMessage=task.statusMessage, + createdAt=task.createdAt, + lastUpdatedAt=task.lastUpdatedAt, + ttl=task.ttl, + pollInterval=task.pollInterval, + ) + + # Set up streams + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def message_handler( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: ... # pragma: no cover + + async def run_server(app_context: AppContext): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) as server_session: + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, app_context, raise_exceptions=False) + + async with anyio.create_task_group() as tg: + app_context = AppContext(task_group=tg, store=store) + tg.start_soon(run_server, app_context) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=message_handler, + ) as client_session: + await client_session.initialize() + + # Send task request + create_result = await client_session.send_request( + ClientRequest( + CallToolRequest( + params=CallToolRequestParams( + name="failing_task", + arguments={}, + task=TaskMetadata(ttl=60000), + ), + ) + ), + CreateTaskResult, + ) + + task_id = create_result.task.taskId + + # Wait for task to complete (even though it fails) + await app_context.task_done_events[task_id].wait() + + # Check that task was auto-failed + task_status = await client_session.send_request( + ClientRequest(GetTaskRequest(params=GetTaskRequestParams(taskId=task_id))), + GetTaskResult, + ) + + assert task_status.status == "failed" + assert task_status.statusMessage == "Something went wrong!" + + tg.cancel_scope.cancel() diff --git a/tests/experimental/tasks/server/test_run_task_flow.py b/tests/experimental/tasks/server/test_run_task_flow.py new file mode 100644 index 0000000000..7f680beb66 --- /dev/null +++ b/tests/experimental/tasks/server/test_run_task_flow.py @@ -0,0 +1,538 @@ +""" +Tests for the simplified task API: enable_tasks() + run_task() + +This tests the recommended user flow: +1. server.experimental.enable_tasks() - one-line setup +2. ctx.experimental.run_task(work) - spawns work, returns CreateTaskResult +3. work function uses ServerTaskContext for elicit/create_message + +These are integration tests that verify the complete flow works end-to-end. +""" + +from typing import Any +from unittest.mock import Mock + +import anyio +import pytest +from anyio import Event + +from mcp.client.session import ClientSession +from mcp.server import Server +from mcp.server.experimental.request_context import Experimental +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.server.experimental.task_support import TaskSupport +from mcp.server.lowlevel import NotificationOptions +from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore +from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue +from mcp.shared.message import SessionMessage +from mcp.types import ( + TASK_REQUIRED, + CallToolResult, + CancelTaskRequest, + CancelTaskResult, + CreateTaskResult, + GetTaskPayloadRequest, + GetTaskPayloadResult, + GetTaskRequest, + GetTaskResult, + ListTasksRequest, + ListTasksResult, + TextContent, + Tool, + ToolExecution, +) + + +@pytest.mark.anyio +async def test_run_task_basic_flow() -> None: + """ + Test the basic run_task flow without elicitation. + + 1. enable_tasks() sets up handlers + 2. Client calls tool with task field + 3. run_task() spawns work, returns CreateTaskResult + 4. Work completes in background + 5. Client polls and sees completed status + """ + server = Server("test-run-task") + + # One-line setup + server.experimental.enable_tasks() + + # Track when work completes and capture received meta + work_completed = Event() + received_meta: list[str | None] = [None] + + @server.list_tools() + async def list_tools() -> list[Tool]: + return [ + Tool( + name="simple_task", + description="A simple task", + inputSchema={"type": "object", "properties": {"input": {"type": "string"}}}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + # Capture the meta from the request (if present) + if ctx.meta is not None and ctx.meta.model_extra: # pragma: no branch + received_meta[0] = ctx.meta.model_extra.get("custom_field") + + async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Working...") + input_val = arguments.get("input", "default") + result = CallToolResult(content=[TextContent(type="text", text=f"Processed: {input_val}")]) + work_completed.set() + return result + + return await ctx.experimental.run_task(work) + + # Set up streams + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def run_server() -> None: + await server.run( + client_to_server_receive, + server_to_client_send, + server.create_initialization_options( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ) + + async def run_client() -> None: + async with ClientSession(server_to_client_receive, client_to_server_send) as client_session: + # Initialize + await client_session.initialize() + + # Call tool as task (with meta to test that code path) + result = await client_session.experimental.call_tool_as_task( + "simple_task", + {"input": "hello"}, + meta={"custom_field": "test_value"}, + ) + + # Should get CreateTaskResult + task_id = result.task.taskId + assert result.task.status == "working" + + # Wait for work to complete + with anyio.fail_after(5): + await work_completed.wait() + + # Poll until task status is completed + with anyio.fail_after(5): + while True: + task_status = await client_session.experimental.get_task(task_id) + if task_status.status == "completed": # pragma: no branch + break + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + tg.start_soon(run_client) + + # Verify the meta was passed through correctly + assert received_meta[0] == "test_value" + + +@pytest.mark.anyio +async def test_run_task_auto_fails_on_exception() -> None: + """ + Test that run_task automatically fails the task when work raises. + """ + server = Server("test-run-task-fail") + server.experimental.enable_tasks() + + work_failed = Event() + + @server.list_tools() + async def list_tools() -> list[Tool]: + return [ + Tool( + name="failing_task", + description="A task that fails", + inputSchema={"type": "object"}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + async def work(task: ServerTaskContext) -> CallToolResult: + work_failed.set() + raise RuntimeError("Something went wrong!") + + return await ctx.experimental.run_task(work) + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def run_server() -> None: + await server.run( + client_to_server_receive, + server_to_client_send, + server.create_initialization_options(), + ) + + async def run_client() -> None: + async with ClientSession(server_to_client_receive, client_to_server_send) as client_session: + await client_session.initialize() + + result = await client_session.experimental.call_tool_as_task("failing_task", {}) + task_id = result.task.taskId + + # Wait for work to fail + with anyio.fail_after(5): + await work_failed.wait() + + # Poll until task status is failed + with anyio.fail_after(5): + while True: + task_status = await client_session.experimental.get_task(task_id) + if task_status.status == "failed": # pragma: no branch + break + + assert "Something went wrong" in (task_status.statusMessage or "") + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + tg.start_soon(run_client) + + +@pytest.mark.anyio +async def test_enable_tasks_auto_registers_handlers() -> None: + """ + Test that enable_tasks() auto-registers get_task, list_tasks, cancel_task handlers. + """ + server = Server("test-enable-tasks") + + # Before enable_tasks, no task capabilities + caps_before = server.get_capabilities(NotificationOptions(), {}) + assert caps_before.tasks is None + + # Enable tasks + server.experimental.enable_tasks() + + # After enable_tasks, should have task capabilities + caps_after = server.get_capabilities(NotificationOptions(), {}) + assert caps_after.tasks is not None + assert caps_after.tasks.list is not None + assert caps_after.tasks.cancel is not None + + +@pytest.mark.anyio +async def test_enable_tasks_with_custom_store_and_queue() -> None: + """Test that enable_tasks() uses provided store and queue instead of defaults.""" + server = Server("test-custom-store-queue") + + # Create custom store and queue + custom_store = InMemoryTaskStore() + custom_queue = InMemoryTaskMessageQueue() + + # Enable tasks with custom implementations + task_support = server.experimental.enable_tasks(store=custom_store, queue=custom_queue) + + # Verify our custom implementations are used + assert task_support.store is custom_store + assert task_support.queue is custom_queue + + +@pytest.mark.anyio +async def test_enable_tasks_skips_default_handlers_when_custom_registered() -> None: + """Test that enable_tasks() doesn't override already-registered handlers.""" + server = Server("test-custom-handlers") + + # Register custom handlers BEFORE enable_tasks (never called, just for registration) + @server.experimental.get_task() + async def custom_get_task(req: GetTaskRequest) -> GetTaskResult: + raise NotImplementedError + + @server.experimental.get_task_result() + async def custom_get_task_result(req: GetTaskPayloadRequest) -> GetTaskPayloadResult: + raise NotImplementedError + + @server.experimental.list_tasks() + async def custom_list_tasks(req: ListTasksRequest) -> ListTasksResult: + raise NotImplementedError + + @server.experimental.cancel_task() + async def custom_cancel_task(req: CancelTaskRequest) -> CancelTaskResult: + raise NotImplementedError + + # Now enable tasks - should NOT override our custom handlers + server.experimental.enable_tasks() + + # Verify our custom handlers are still registered (not replaced by defaults) + # The handlers dict should contain our custom handlers + assert GetTaskRequest in server.request_handlers + assert GetTaskPayloadRequest in server.request_handlers + assert ListTasksRequest in server.request_handlers + assert CancelTaskRequest in server.request_handlers + + +@pytest.mark.anyio +async def test_run_task_without_enable_tasks_raises() -> None: + """Test that run_task raises when enable_tasks() wasn't called.""" + experimental = Experimental( + task_metadata=None, + _client_capabilities=None, + _session=None, + _task_support=None, # Not enabled + ) + + async def work(task: ServerTaskContext) -> CallToolResult: + raise NotImplementedError + + with pytest.raises(RuntimeError, match="Task support not enabled"): + await experimental.run_task(work) + + +@pytest.mark.anyio +async def test_task_support_task_group_before_run_raises() -> None: + """Test that accessing task_group before run() raises RuntimeError.""" + task_support = TaskSupport.in_memory() + + with pytest.raises(RuntimeError, match="TaskSupport not running"): + _ = task_support.task_group + + +@pytest.mark.anyio +async def test_run_task_without_session_raises() -> None: + """Test that run_task raises when session is not available.""" + task_support = TaskSupport.in_memory() + + experimental = Experimental( + task_metadata=None, + _client_capabilities=None, + _session=None, # No session + _task_support=task_support, + ) + + async def work(task: ServerTaskContext) -> CallToolResult: + raise NotImplementedError + + with pytest.raises(RuntimeError, match="Session not available"): + await experimental.run_task(work) + + +@pytest.mark.anyio +async def test_run_task_without_task_metadata_raises() -> None: + """Test that run_task raises when request is not task-augmented.""" + task_support = TaskSupport.in_memory() + mock_session = Mock() + + experimental = Experimental( + task_metadata=None, # Not a task-augmented request + _client_capabilities=None, + _session=mock_session, + _task_support=task_support, + ) + + async def work(task: ServerTaskContext) -> CallToolResult: + raise NotImplementedError + + with pytest.raises(RuntimeError, match="Request is not task-augmented"): + await experimental.run_task(work) + + +@pytest.mark.anyio +async def test_run_task_with_model_immediate_response() -> None: + """Test that run_task includes model_immediate_response in CreateTaskResult._meta.""" + server = Server("test-run-task-immediate") + server.experimental.enable_tasks() + + work_completed = Event() + immediate_response_text = "Processing your request..." + + @server.list_tools() + async def list_tools() -> list[Tool]: + return [ + Tool( + name="task_with_immediate", + description="A task with immediate response", + inputSchema={"type": "object"}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + async def work(task: ServerTaskContext) -> CallToolResult: + work_completed.set() + return CallToolResult(content=[TextContent(type="text", text="Done")]) + + return await ctx.experimental.run_task(work, model_immediate_response=immediate_response_text) + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def run_server() -> None: + await server.run( + client_to_server_receive, + server_to_client_send, + server.create_initialization_options(), + ) + + async def run_client() -> None: + async with ClientSession(server_to_client_receive, client_to_server_send) as client_session: + await client_session.initialize() + + result = await client_session.experimental.call_tool_as_task("task_with_immediate", {}) + + # Verify the immediate response is in _meta + assert result.meta is not None + assert "io.modelcontextprotocol/model-immediate-response" in result.meta + assert result.meta["io.modelcontextprotocol/model-immediate-response"] == immediate_response_text + + with anyio.fail_after(5): + await work_completed.wait() + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + tg.start_soon(run_client) + + +@pytest.mark.anyio +async def test_run_task_doesnt_complete_if_already_terminal() -> None: + """Test that run_task doesn't auto-complete if work manually completed the task.""" + server = Server("test-already-complete") + server.experimental.enable_tasks() + + work_completed = Event() + + @server.list_tools() + async def list_tools() -> list[Tool]: + return [ + Tool( + name="manual_complete_task", + description="A task that manually completes", + inputSchema={"type": "object"}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + async def work(task: ServerTaskContext) -> CallToolResult: + # Manually complete the task before returning + manual_result = CallToolResult(content=[TextContent(type="text", text="Manually completed")]) + await task.complete(manual_result, notify=False) + work_completed.set() + # Return a different result - but it should be ignored since task is already terminal + return CallToolResult(content=[TextContent(type="text", text="This should be ignored")]) + + return await ctx.experimental.run_task(work) + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def run_server() -> None: + await server.run( + client_to_server_receive, + server_to_client_send, + server.create_initialization_options(), + ) + + async def run_client() -> None: + async with ClientSession(server_to_client_receive, client_to_server_send) as client_session: + await client_session.initialize() + + result = await client_session.experimental.call_tool_as_task("manual_complete_task", {}) + task_id = result.task.taskId + + with anyio.fail_after(5): + await work_completed.wait() + + # Poll until task status is completed + with anyio.fail_after(5): + while True: + status = await client_session.experimental.get_task(task_id) + if status.status == "completed": # pragma: no branch + break + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + tg.start_soon(run_client) + + +@pytest.mark.anyio +async def test_run_task_doesnt_fail_if_already_terminal() -> None: + """Test that run_task doesn't auto-fail if work manually failed/cancelled the task.""" + server = Server("test-already-failed") + server.experimental.enable_tasks() + + work_completed = Event() + + @server.list_tools() + async def list_tools() -> list[Tool]: + return [ + Tool( + name="manual_cancel_task", + description="A task that manually cancels then raises", + inputSchema={"type": "object"}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + async def work(task: ServerTaskContext) -> CallToolResult: + # Manually fail the task first + await task.fail("Manually failed", notify=False) + work_completed.set() + # Then raise - but the auto-fail should be skipped since task is already terminal + raise RuntimeError("This error should not change status") + + return await ctx.experimental.run_task(work) + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def run_server() -> None: + await server.run( + client_to_server_receive, + server_to_client_send, + server.create_initialization_options(), + ) + + async def run_client() -> None: + async with ClientSession(server_to_client_receive, client_to_server_send) as client_session: + await client_session.initialize() + + result = await client_session.experimental.call_tool_as_task("manual_cancel_task", {}) + task_id = result.task.taskId + + with anyio.fail_after(5): + await work_completed.wait() + + # Poll until task status is failed + with anyio.fail_after(5): + while True: + status = await client_session.experimental.get_task(task_id) + if status.status == "failed": # pragma: no branch + break + + # Task should still be failed (from manual fail, not auto-fail from exception) + assert status.statusMessage == "Manually failed" # Not "This error should not change status" + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + tg.start_soon(run_client) diff --git a/tests/experimental/tasks/server/test_server.py b/tests/experimental/tasks/server/test_server.py new file mode 100644 index 0000000000..7209ed412a --- /dev/null +++ b/tests/experimental/tasks/server/test_server.py @@ -0,0 +1,965 @@ +"""Tests for server-side task support (handlers, capabilities, integration).""" + +from datetime import datetime, timezone +from typing import Any + +import anyio +import pytest + +from mcp.client.session import ClientSession +from mcp.server import Server +from mcp.server.lowlevel import NotificationOptions +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.exceptions import McpError +from mcp.shared.message import ServerMessageMetadata, SessionMessage +from mcp.shared.response_router import ResponseRouter +from mcp.shared.session import RequestResponder +from mcp.types import ( + INVALID_REQUEST, + TASK_FORBIDDEN, + TASK_OPTIONAL, + TASK_REQUIRED, + CallToolRequest, + CallToolRequestParams, + CallToolResult, + CancelTaskRequest, + CancelTaskRequestParams, + CancelTaskResult, + ClientRequest, + ClientResult, + ErrorData, + GetTaskPayloadRequest, + GetTaskPayloadRequestParams, + GetTaskPayloadResult, + GetTaskRequest, + GetTaskRequestParams, + GetTaskResult, + JSONRPCError, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCResponse, + ListTasksRequest, + ListTasksResult, + ListToolsRequest, + ListToolsResult, + SamplingMessage, + ServerCapabilities, + ServerNotification, + ServerRequest, + ServerResult, + Task, + TaskMetadata, + TextContent, + Tool, + ToolExecution, +) + + +@pytest.mark.anyio +async def test_list_tasks_handler() -> None: + """Test that experimental list_tasks handler works.""" + server = Server("test") + + now = datetime.now(timezone.utc) + test_tasks = [ + Task( + taskId="task-1", + status="working", + createdAt=now, + lastUpdatedAt=now, + ttl=60000, + pollInterval=1000, + ), + Task( + taskId="task-2", + status="completed", + createdAt=now, + lastUpdatedAt=now, + ttl=60000, + pollInterval=1000, + ), + ] + + @server.experimental.list_tasks() + async def handle_list_tasks(request: ListTasksRequest) -> ListTasksResult: + return ListTasksResult(tasks=test_tasks) + + handler = server.request_handlers[ListTasksRequest] + request = ListTasksRequest(method="tasks/list") + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, ListTasksResult) + assert len(result.root.tasks) == 2 + assert result.root.tasks[0].taskId == "task-1" + assert result.root.tasks[1].taskId == "task-2" + + +@pytest.mark.anyio +async def test_get_task_handler() -> None: + """Test that experimental get_task handler works.""" + server = Server("test") + + @server.experimental.get_task() + async def handle_get_task(request: GetTaskRequest) -> GetTaskResult: + now = datetime.now(timezone.utc) + return GetTaskResult( + taskId=request.params.taskId, + status="working", + createdAt=now, + lastUpdatedAt=now, + ttl=60000, + pollInterval=1000, + ) + + handler = server.request_handlers[GetTaskRequest] + request = GetTaskRequest( + method="tasks/get", + params=GetTaskRequestParams(taskId="test-task-123"), + ) + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, GetTaskResult) + assert result.root.taskId == "test-task-123" + assert result.root.status == "working" + + +@pytest.mark.anyio +async def test_get_task_result_handler() -> None: + """Test that experimental get_task_result handler works.""" + server = Server("test") + + @server.experimental.get_task_result() + async def handle_get_task_result(request: GetTaskPayloadRequest) -> GetTaskPayloadResult: + return GetTaskPayloadResult() + + handler = server.request_handlers[GetTaskPayloadRequest] + request = GetTaskPayloadRequest( + method="tasks/result", + params=GetTaskPayloadRequestParams(taskId="test-task-123"), + ) + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, GetTaskPayloadResult) + + +@pytest.mark.anyio +async def test_cancel_task_handler() -> None: + """Test that experimental cancel_task handler works.""" + server = Server("test") + + @server.experimental.cancel_task() + async def handle_cancel_task(request: CancelTaskRequest) -> CancelTaskResult: + now = datetime.now(timezone.utc) + return CancelTaskResult( + taskId=request.params.taskId, + status="cancelled", + createdAt=now, + lastUpdatedAt=now, + ttl=60000, + ) + + handler = server.request_handlers[CancelTaskRequest] + request = CancelTaskRequest( + method="tasks/cancel", + params=CancelTaskRequestParams(taskId="test-task-123"), + ) + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, CancelTaskResult) + assert result.root.taskId == "test-task-123" + assert result.root.status == "cancelled" + + +@pytest.mark.anyio +async def test_server_capabilities_include_tasks() -> None: + """Test that server capabilities include tasks when handlers are registered.""" + server = Server("test") + + @server.experimental.list_tasks() + async def handle_list_tasks(request: ListTasksRequest) -> ListTasksResult: + raise NotImplementedError + + @server.experimental.cancel_task() + async def handle_cancel_task(request: CancelTaskRequest) -> CancelTaskResult: + raise NotImplementedError + + capabilities = server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ) + + assert capabilities.tasks is not None + assert capabilities.tasks.list is not None + assert capabilities.tasks.cancel is not None + assert capabilities.tasks.requests is not None + assert capabilities.tasks.requests.tools is not None + + +@pytest.mark.anyio +async def test_server_capabilities_partial_tasks() -> None: + """Test capabilities with only some task handlers registered.""" + server = Server("test") + + @server.experimental.list_tasks() + async def handle_list_tasks(request: ListTasksRequest) -> ListTasksResult: + raise NotImplementedError + + # Only list_tasks registered, not cancel_task + + capabilities = server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ) + + assert capabilities.tasks is not None + assert capabilities.tasks.list is not None + assert capabilities.tasks.cancel is None # Not registered + + +@pytest.mark.anyio +async def test_tool_with_task_execution_metadata() -> None: + """Test that tools can declare task execution mode.""" + server = Server("test") + + @server.list_tools() + async def list_tools(): + return [ + Tool( + name="quick_tool", + description="Fast tool", + inputSchema={"type": "object", "properties": {}}, + execution=ToolExecution(taskSupport=TASK_FORBIDDEN), + ), + Tool( + name="long_tool", + description="Long running tool", + inputSchema={"type": "object", "properties": {}}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ), + Tool( + name="flexible_tool", + description="Can be either", + inputSchema={"type": "object", "properties": {}}, + execution=ToolExecution(taskSupport=TASK_OPTIONAL), + ), + ] + + tools_handler = server.request_handlers[ListToolsRequest] + request = ListToolsRequest(method="tools/list") + result = await tools_handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, ListToolsResult) + tools = result.root.tools + + assert tools[0].execution is not None + assert tools[0].execution.taskSupport == TASK_FORBIDDEN + assert tools[1].execution is not None + assert tools[1].execution.taskSupport == TASK_REQUIRED + assert tools[2].execution is not None + assert tools[2].execution.taskSupport == TASK_OPTIONAL + + +@pytest.mark.anyio +async def test_task_metadata_in_call_tool_request() -> None: + """Test that task metadata is accessible via RequestContext when calling a tool.""" + server = Server("test") + captured_task_metadata: TaskMetadata | None = None + + @server.list_tools() + async def list_tools(): + return [ + Tool( + name="long_task", + description="A long running task", + inputSchema={"type": "object", "properties": {}}, + execution=ToolExecution(taskSupport="optional"), + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: + nonlocal captured_task_metadata + ctx = server.request_context + captured_task_metadata = ctx.experimental.task_metadata + return [TextContent(type="text", text="done")] + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def message_handler( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: ... # pragma: no branch + + async def run_server(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) as server_session: + async with anyio.create_task_group() as tg: + + async def handle_messages(): + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, {}, False) + + tg.start_soon(handle_messages) + await anyio.sleep_forever() + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=message_handler, + ) as client_session: + await client_session.initialize() + + # Call tool with task metadata + await client_session.send_request( + ClientRequest( + CallToolRequest( + params=CallToolRequestParams( + name="long_task", + arguments={}, + task=TaskMetadata(ttl=60000), + ), + ) + ), + CallToolResult, + ) + + tg.cancel_scope.cancel() + + assert captured_task_metadata is not None + assert captured_task_metadata.ttl == 60000 + + +@pytest.mark.anyio +async def test_task_metadata_is_task_property() -> None: + """Test that RequestContext.experimental.is_task works correctly.""" + server = Server("test") + is_task_values: list[bool] = [] + + @server.list_tools() + async def list_tools(): + return [ + Tool( + name="test_tool", + description="Test tool", + inputSchema={"type": "object", "properties": {}}, + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: + ctx = server.request_context + is_task_values.append(ctx.experimental.is_task) + return [TextContent(type="text", text="done")] + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def message_handler( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: ... # pragma: no branch + + async def run_server(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) as server_session: + async with anyio.create_task_group() as tg: + + async def handle_messages(): + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, {}, False) + + tg.start_soon(handle_messages) + await anyio.sleep_forever() + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=message_handler, + ) as client_session: + await client_session.initialize() + + # Call without task metadata + await client_session.send_request( + ClientRequest( + CallToolRequest( + params=CallToolRequestParams(name="test_tool", arguments={}), + ) + ), + CallToolResult, + ) + + # Call with task metadata + await client_session.send_request( + ClientRequest( + CallToolRequest( + params=CallToolRequestParams( + name="test_tool", + arguments={}, + task=TaskMetadata(ttl=60000), + ), + ) + ), + CallToolResult, + ) + + tg.cancel_scope.cancel() + + assert len(is_task_values) == 2 + assert is_task_values[0] is False # First call without task + assert is_task_values[1] is True # Second call with task + + +@pytest.mark.anyio +async def test_update_capabilities_no_handlers() -> None: + """Test that update_capabilities returns early when no task handlers are registered.""" + server = Server("test-no-handlers") + # Access experimental to initialize it, but don't register any task handlers + _ = server.experimental + + caps = server.get_capabilities(NotificationOptions(), {}) + + # Without any task handlers registered, tasks capability should be None + assert caps.tasks is None + + +@pytest.mark.anyio +async def test_default_task_handlers_via_enable_tasks() -> None: + """Test that enable_tasks() auto-registers working default handlers. + + This exercises the default handlers in lowlevel/experimental.py: + - _default_get_task (task not found) + - _default_get_task_result + - _default_list_tasks + - _default_cancel_task + """ + server = Server("test-default-handlers") + # Enable tasks with default handlers (no custom handlers registered) + task_support = server.experimental.enable_tasks() + store = task_support.store + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def message_handler( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: ... # pragma: no branch + + async def run_server() -> None: + async with task_support.run(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) as server_session: + task_support.configure_session(server_session) + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, {}, False) + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=message_handler, + ) as client_session: + await client_session.initialize() + + # Create a task directly in the store for testing + task = await store.create_task(TaskMetadata(ttl=60000)) + + # Test list_tasks (default handler) + list_result = await client_session.send_request( + ClientRequest(ListTasksRequest()), + ListTasksResult, + ) + assert len(list_result.tasks) == 1 + assert list_result.tasks[0].taskId == task.taskId + + # Test get_task (default handler - found) + get_result = await client_session.send_request( + ClientRequest(GetTaskRequest(params=GetTaskRequestParams(taskId=task.taskId))), + GetTaskResult, + ) + assert get_result.taskId == task.taskId + assert get_result.status == "working" + + # Test get_task (default handler - not found path) + with pytest.raises(McpError, match="not found"): + await client_session.send_request( + ClientRequest(GetTaskRequest(params=GetTaskRequestParams(taskId="nonexistent-task"))), + GetTaskResult, + ) + + # Create a completed task to test get_task_result + completed_task = await store.create_task(TaskMetadata(ttl=60000)) + await store.store_result( + completed_task.taskId, CallToolResult(content=[TextContent(type="text", text="Test result")]) + ) + await store.update_task(completed_task.taskId, status="completed") + + # Test get_task_result (default handler) + payload_result = await client_session.send_request( + ClientRequest(GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=completed_task.taskId))), + GetTaskPayloadResult, + ) + # The result should have the related-task metadata + assert payload_result.meta is not None + assert "io.modelcontextprotocol/related-task" in payload_result.meta + + # Test cancel_task (default handler) + cancel_result = await client_session.send_request( + ClientRequest(CancelTaskRequest(params=CancelTaskRequestParams(taskId=task.taskId))), + CancelTaskResult, + ) + assert cancel_result.taskId == task.taskId + assert cancel_result.status == "cancelled" + + tg.cancel_scope.cancel() + + +@pytest.mark.anyio +async def test_build_elicit_form_request() -> None: + """Test that _build_elicit_form_request builds a proper elicitation request.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + try: + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=ServerCapabilities(), + ), + ) as server_session: + # Test without task_id + request = server_session._build_elicit_form_request( + message="Test message", + requestedSchema={"type": "object", "properties": {"answer": {"type": "string"}}}, + ) + assert request.method == "elicitation/create" + assert request.params is not None + assert request.params["message"] == "Test message" + + # Test with related_task_id (adds related-task metadata) + request_with_task = server_session._build_elicit_form_request( + message="Task message", + requestedSchema={"type": "object"}, + related_task_id="test-task-123", + ) + assert request_with_task.method == "elicitation/create" + assert request_with_task.params is not None + assert "_meta" in request_with_task.params + assert "io.modelcontextprotocol/related-task" in request_with_task.params["_meta"] + assert ( + request_with_task.params["_meta"]["io.modelcontextprotocol/related-task"]["taskId"] == "test-task-123" + ) + finally: # pragma: no cover + await server_to_client_send.aclose() + await server_to_client_receive.aclose() + await client_to_server_send.aclose() + await client_to_server_receive.aclose() + + +@pytest.mark.anyio +async def test_build_elicit_url_request() -> None: + """Test that _build_elicit_url_request builds a proper URL mode elicitation request.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + try: + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=ServerCapabilities(), + ), + ) as server_session: + # Test without related_task_id + request = server_session._build_elicit_url_request( + message="Please authorize with GitHub", + url="https://github.com/login/oauth/authorize", + elicitation_id="oauth-123", + ) + assert request.method == "elicitation/create" + assert request.params is not None + assert request.params["message"] == "Please authorize with GitHub" + assert request.params["url"] == "https://github.com/login/oauth/authorize" + assert request.params["elicitationId"] == "oauth-123" + assert request.params["mode"] == "url" + + # Test with related_task_id (adds related-task metadata) + request_with_task = server_session._build_elicit_url_request( + message="OAuth required", + url="https://example.com/oauth", + elicitation_id="oauth-456", + related_task_id="test-task-789", + ) + assert request_with_task.method == "elicitation/create" + assert request_with_task.params is not None + assert "_meta" in request_with_task.params + assert "io.modelcontextprotocol/related-task" in request_with_task.params["_meta"] + assert ( + request_with_task.params["_meta"]["io.modelcontextprotocol/related-task"]["taskId"] == "test-task-789" + ) + finally: # pragma: no cover + await server_to_client_send.aclose() + await server_to_client_receive.aclose() + await client_to_server_send.aclose() + await client_to_server_receive.aclose() + + +@pytest.mark.anyio +async def test_build_create_message_request() -> None: + """Test that _build_create_message_request builds a proper sampling request.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + try: + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=ServerCapabilities(), + ), + ) as server_session: + messages = [ + SamplingMessage(role="user", content=TextContent(type="text", text="Hello")), + ] + + # Test without task_id + request = server_session._build_create_message_request( + messages=messages, + max_tokens=100, + system_prompt="You are helpful", + ) + assert request.method == "sampling/createMessage" + assert request.params is not None + assert request.params["maxTokens"] == 100 + + # Test with related_task_id (adds related-task metadata) + request_with_task = server_session._build_create_message_request( + messages=messages, + max_tokens=50, + related_task_id="sampling-task-456", + ) + assert request_with_task.method == "sampling/createMessage" + assert request_with_task.params is not None + assert "_meta" in request_with_task.params + assert "io.modelcontextprotocol/related-task" in request_with_task.params["_meta"] + assert ( + request_with_task.params["_meta"]["io.modelcontextprotocol/related-task"]["taskId"] + == "sampling-task-456" + ) + finally: # pragma: no cover + await server_to_client_send.aclose() + await server_to_client_receive.aclose() + await client_to_server_send.aclose() + await client_to_server_receive.aclose() + + +@pytest.mark.anyio +async def test_send_message() -> None: + """Test that send_message sends a raw session message.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + try: + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=ServerCapabilities(), + ), + ) as server_session: + # Create a test message + notification = JSONRPCNotification(jsonrpc="2.0", method="test/notification") + message = SessionMessage( + message=JSONRPCMessage(notification), + metadata=ServerMessageMetadata(related_request_id="test-req-1"), + ) + + # Send the message + await server_session.send_message(message) + + # Verify it was sent to the stream + received = await server_to_client_receive.receive() + assert isinstance(received.message.root, JSONRPCNotification) + assert received.message.root.method == "test/notification" + finally: # pragma: no cover + await server_to_client_send.aclose() + await server_to_client_receive.aclose() + await client_to_server_send.aclose() + await client_to_server_receive.aclose() + + +@pytest.mark.anyio +async def test_response_routing_success() -> None: + """Test that response routing works for success responses.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + # Track routed responses with event for synchronization + routed_responses: list[dict[str, Any]] = [] + response_received = anyio.Event() + + class TestRouter(ResponseRouter): + def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool: + routed_responses.append({"id": request_id, "response": response}) + response_received.set() + return True # Handled + + def route_error(self, request_id: str | int, error: ErrorData) -> bool: + raise NotImplementedError + + try: + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=ServerCapabilities(), + ), + ) as server_session: + router = TestRouter() + server_session.add_response_router(router) + + # Simulate receiving a response from client + response = JSONRPCResponse(jsonrpc="2.0", id="test-req-1", result={"status": "ok"}) + message = SessionMessage(message=JSONRPCMessage(response)) + + # Send from "client" side + await client_to_server_send.send(message) + + # Wait for response to be routed + with anyio.fail_after(5): + await response_received.wait() + + # Verify response was routed + assert len(routed_responses) == 1 + assert routed_responses[0]["id"] == "test-req-1" + assert routed_responses[0]["response"]["status"] == "ok" + finally: # pragma: no cover + await server_to_client_send.aclose() + await server_to_client_receive.aclose() + await client_to_server_send.aclose() + await client_to_server_receive.aclose() + + +@pytest.mark.anyio +async def test_response_routing_error() -> None: + """Test that error routing works for error responses.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + # Track routed errors with event for synchronization + routed_errors: list[dict[str, Any]] = [] + error_received = anyio.Event() + + class TestRouter(ResponseRouter): + def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool: + raise NotImplementedError + + def route_error(self, request_id: str | int, error: ErrorData) -> bool: + routed_errors.append({"id": request_id, "error": error}) + error_received.set() + return True # Handled + + try: + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=ServerCapabilities(), + ), + ) as server_session: + router = TestRouter() + server_session.add_response_router(router) + + # Simulate receiving an error response from client + error_data = ErrorData(code=INVALID_REQUEST, message="Test error") + error_response = JSONRPCError(jsonrpc="2.0", id="test-req-2", error=error_data) + message = SessionMessage(message=JSONRPCMessage(error_response)) + + # Send from "client" side + await client_to_server_send.send(message) + + # Wait for error to be routed + with anyio.fail_after(5): + await error_received.wait() + + # Verify error was routed + assert len(routed_errors) == 1 + assert routed_errors[0]["id"] == "test-req-2" + assert routed_errors[0]["error"].message == "Test error" + finally: # pragma: no cover + await server_to_client_send.aclose() + await server_to_client_receive.aclose() + await client_to_server_send.aclose() + await client_to_server_receive.aclose() + + +@pytest.mark.anyio +async def test_response_routing_skips_non_matching_routers() -> None: + """Test that routing continues to next router when first doesn't match.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + # Track which routers were called + router_calls: list[str] = [] + response_received = anyio.Event() + + class NonMatchingRouter(ResponseRouter): + def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool: + router_calls.append("non_matching_response") + return False # Doesn't handle it + + def route_error(self, request_id: str | int, error: ErrorData) -> bool: + raise NotImplementedError + + class MatchingRouter(ResponseRouter): + def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool: + router_calls.append("matching_response") + response_received.set() + return True # Handles it + + def route_error(self, request_id: str | int, error: ErrorData) -> bool: + raise NotImplementedError + + try: + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=ServerCapabilities(), + ), + ) as server_session: + # Add non-matching router first, then matching router + server_session.add_response_router(NonMatchingRouter()) + server_session.add_response_router(MatchingRouter()) + + # Send a response - should skip first router and be handled by second + response = JSONRPCResponse(jsonrpc="2.0", id="test-req-1", result={"status": "ok"}) + message = SessionMessage(message=JSONRPCMessage(response)) + await client_to_server_send.send(message) + + with anyio.fail_after(5): + await response_received.wait() + + # Verify both routers were called (first returned False, second returned True) + assert router_calls == ["non_matching_response", "matching_response"] + finally: # pragma: no cover + await server_to_client_send.aclose() + await server_to_client_receive.aclose() + await client_to_server_send.aclose() + await client_to_server_receive.aclose() + + +@pytest.mark.anyio +async def test_error_routing_skips_non_matching_routers() -> None: + """Test that error routing continues to next router when first doesn't match.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + # Track which routers were called + router_calls: list[str] = [] + error_received = anyio.Event() + + class NonMatchingRouter(ResponseRouter): + def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool: + raise NotImplementedError + + def route_error(self, request_id: str | int, error: ErrorData) -> bool: + router_calls.append("non_matching_error") + return False # Doesn't handle it + + class MatchingRouter(ResponseRouter): + def route_response(self, request_id: str | int, response: dict[str, Any]) -> bool: + raise NotImplementedError + + def route_error(self, request_id: str | int, error: ErrorData) -> bool: + router_calls.append("matching_error") + error_received.set() + return True # Handles it + + try: + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=ServerCapabilities(), + ), + ) as server_session: + # Add non-matching router first, then matching router + server_session.add_response_router(NonMatchingRouter()) + server_session.add_response_router(MatchingRouter()) + + # Send an error - should skip first router and be handled by second + error_data = ErrorData(code=INVALID_REQUEST, message="Test error") + error_response = JSONRPCError(jsonrpc="2.0", id="test-req-2", error=error_data) + message = SessionMessage(message=JSONRPCMessage(error_response)) + await client_to_server_send.send(message) + + with anyio.fail_after(5): + await error_received.wait() + + # Verify both routers were called (first returned False, second returned True) + assert router_calls == ["non_matching_error", "matching_error"] + finally: # pragma: no cover + await server_to_client_send.aclose() + await server_to_client_receive.aclose() + await client_to_server_send.aclose() + await client_to_server_receive.aclose() diff --git a/tests/experimental/tasks/server/test_server_task_context.py b/tests/experimental/tasks/server/test_server_task_context.py new file mode 100644 index 0000000000..3d6b16f482 --- /dev/null +++ b/tests/experimental/tasks/server/test_server_task_context.py @@ -0,0 +1,709 @@ +"""Tests for ServerTaskContext.""" + +import asyncio +from unittest.mock import AsyncMock, Mock + +import anyio +import pytest + +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.server.experimental.task_result_handler import TaskResultHandler +from mcp.shared.exceptions import McpError +from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore +from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue +from mcp.types import ( + CallToolResult, + ClientCapabilities, + ClientTasksCapability, + ClientTasksRequestsCapability, + Implementation, + InitializeRequestParams, + JSONRPCRequest, + SamplingMessage, + TaskMetadata, + TasksCreateElicitationCapability, + TasksCreateMessageCapability, + TasksElicitationCapability, + TasksSamplingCapability, + TextContent, +) + + +@pytest.mark.anyio +async def test_server_task_context_properties() -> None: + """Test ServerTaskContext property accessors.""" + store = InMemoryTaskStore() + mock_session = Mock() + queue = InMemoryTaskMessageQueue() + task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-123") + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + ) + + assert ctx.task_id == "test-123" + assert ctx.task.taskId == "test-123" + assert ctx.is_cancelled is False + + store.cleanup() + + +@pytest.mark.anyio +async def test_server_task_context_request_cancellation() -> None: + """Test ServerTaskContext.request_cancellation().""" + store = InMemoryTaskStore() + mock_session = Mock() + queue = InMemoryTaskMessageQueue() + task = await store.create_task(TaskMetadata(ttl=60000)) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + ) + + assert ctx.is_cancelled is False + ctx.request_cancellation() + assert ctx.is_cancelled is True + + store.cleanup() + + +@pytest.mark.anyio +async def test_server_task_context_update_status_with_notify() -> None: + """Test update_status sends notification when notify=True.""" + store = InMemoryTaskStore() + mock_session = Mock() + mock_session.send_notification = AsyncMock() + queue = InMemoryTaskMessageQueue() + task = await store.create_task(TaskMetadata(ttl=60000)) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + ) + + await ctx.update_status("Working...", notify=True) + + mock_session.send_notification.assert_called_once() + store.cleanup() + + +@pytest.mark.anyio +async def test_server_task_context_update_status_without_notify() -> None: + """Test update_status skips notification when notify=False.""" + store = InMemoryTaskStore() + mock_session = Mock() + mock_session.send_notification = AsyncMock() + queue = InMemoryTaskMessageQueue() + task = await store.create_task(TaskMetadata(ttl=60000)) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + ) + + await ctx.update_status("Working...", notify=False) + + mock_session.send_notification.assert_not_called() + store.cleanup() + + +@pytest.mark.anyio +async def test_server_task_context_complete_with_notify() -> None: + """Test complete sends notification when notify=True.""" + store = InMemoryTaskStore() + mock_session = Mock() + mock_session.send_notification = AsyncMock() + queue = InMemoryTaskMessageQueue() + task = await store.create_task(TaskMetadata(ttl=60000)) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + ) + + result = CallToolResult(content=[TextContent(type="text", text="Done")]) + await ctx.complete(result, notify=True) + + mock_session.send_notification.assert_called_once() + store.cleanup() + + +@pytest.mark.anyio +async def test_server_task_context_fail_with_notify() -> None: + """Test fail sends notification when notify=True.""" + store = InMemoryTaskStore() + mock_session = Mock() + mock_session.send_notification = AsyncMock() + queue = InMemoryTaskMessageQueue() + task = await store.create_task(TaskMetadata(ttl=60000)) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + ) + + await ctx.fail("Something went wrong", notify=True) + + mock_session.send_notification.assert_called_once() + store.cleanup() + + +@pytest.mark.anyio +async def test_elicit_raises_when_client_lacks_capability() -> None: + """Test that elicit() raises McpError when client doesn't support elicitation.""" + store = InMemoryTaskStore() + mock_session = Mock() + mock_session.check_client_capability = Mock(return_value=False) + queue = InMemoryTaskMessageQueue() + handler = TaskResultHandler(store, queue) + task = await store.create_task(TaskMetadata(ttl=60000)) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + handler=handler, + ) + + with pytest.raises(McpError) as exc_info: + await ctx.elicit(message="Test?", requestedSchema={"type": "object"}) + + assert "elicitation capability" in exc_info.value.error.message + mock_session.check_client_capability.assert_called_once() + store.cleanup() + + +@pytest.mark.anyio +async def test_create_message_raises_when_client_lacks_capability() -> None: + """Test that create_message() raises McpError when client doesn't support sampling.""" + store = InMemoryTaskStore() + mock_session = Mock() + mock_session.check_client_capability = Mock(return_value=False) + queue = InMemoryTaskMessageQueue() + handler = TaskResultHandler(store, queue) + task = await store.create_task(TaskMetadata(ttl=60000)) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + handler=handler, + ) + + with pytest.raises(McpError) as exc_info: + await ctx.create_message(messages=[], max_tokens=100) + + assert "sampling capability" in exc_info.value.error.message + mock_session.check_client_capability.assert_called_once() + store.cleanup() + + +@pytest.mark.anyio +async def test_elicit_raises_without_handler() -> None: + """Test that elicit() raises when handler is not provided.""" + store = InMemoryTaskStore() + mock_session = Mock() + mock_session.check_client_capability = Mock(return_value=True) + queue = InMemoryTaskMessageQueue() + task = await store.create_task(TaskMetadata(ttl=60000)) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + handler=None, + ) + + with pytest.raises(RuntimeError, match="handler is required"): + await ctx.elicit(message="Test?", requestedSchema={"type": "object"}) + + store.cleanup() + + +@pytest.mark.anyio +async def test_elicit_url_raises_without_handler() -> None: + """Test that elicit_url() raises when handler is not provided.""" + store = InMemoryTaskStore() + mock_session = Mock() + mock_session.check_client_capability = Mock(return_value=True) + queue = InMemoryTaskMessageQueue() + task = await store.create_task(TaskMetadata(ttl=60000)) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + handler=None, + ) + + with pytest.raises(RuntimeError, match="handler is required for elicit_url"): + await ctx.elicit_url( + message="Please authorize", + url="https://example.com/oauth", + elicitation_id="oauth-123", + ) + + store.cleanup() + + +@pytest.mark.anyio +async def test_create_message_raises_without_handler() -> None: + """Test that create_message() raises when handler is not provided.""" + store = InMemoryTaskStore() + mock_session = Mock() + mock_session.check_client_capability = Mock(return_value=True) + queue = InMemoryTaskMessageQueue() + task = await store.create_task(TaskMetadata(ttl=60000)) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + handler=None, + ) + + with pytest.raises(RuntimeError, match="handler is required"): + await ctx.create_message(messages=[], max_tokens=100) + + store.cleanup() + + +@pytest.mark.anyio +async def test_elicit_queues_request_and_waits_for_response() -> None: + """Test that elicit() queues request and waits for response.""" + store = InMemoryTaskStore() + queue = InMemoryTaskMessageQueue() + handler = TaskResultHandler(store, queue) + task = await store.create_task(TaskMetadata(ttl=60000)) + + mock_session = Mock() + mock_session.check_client_capability = Mock(return_value=True) + mock_session._build_elicit_form_request = Mock( + return_value=JSONRPCRequest( + jsonrpc="2.0", + id="test-req-1", + method="elicitation/create", + params={"message": "Test?", "_meta": {}}, + ) + ) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + handler=handler, + ) + + elicit_result = None + + async def run_elicit() -> None: + nonlocal elicit_result + elicit_result = await ctx.elicit( + message="Test?", + requestedSchema={"type": "object"}, + ) + + async with anyio.create_task_group() as tg: + tg.start_soon(run_elicit) + + # Wait for request to be queued + await queue.wait_for_message(task.taskId) + + # Verify task is in input_required status + updated_task = await store.get_task(task.taskId) + assert updated_task is not None + assert updated_task.status == "input_required" + + # Dequeue and simulate response + msg = await queue.dequeue(task.taskId) + assert msg is not None + assert msg.resolver is not None + + # Resolve with mock elicitation response + msg.resolver.set_result({"action": "accept", "content": {"name": "Alice"}}) + + # Verify result + assert elicit_result is not None + assert elicit_result.action == "accept" + assert elicit_result.content == {"name": "Alice"} + + # Verify task is back to working + final_task = await store.get_task(task.taskId) + assert final_task is not None + assert final_task.status == "working" + + store.cleanup() + + +@pytest.mark.anyio +async def test_elicit_url_queues_request_and_waits_for_response() -> None: + """Test that elicit_url() queues request and waits for response.""" + store = InMemoryTaskStore() + queue = InMemoryTaskMessageQueue() + handler = TaskResultHandler(store, queue) + task = await store.create_task(TaskMetadata(ttl=60000)) + + mock_session = Mock() + mock_session.check_client_capability = Mock(return_value=True) + mock_session._build_elicit_url_request = Mock( + return_value=JSONRPCRequest( + jsonrpc="2.0", + id="test-url-req-1", + method="elicitation/create", + params={"message": "Authorize", "url": "https://example.com", "elicitationId": "123", "mode": "url"}, + ) + ) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + handler=handler, + ) + + elicit_result = None + + async def run_elicit_url() -> None: + nonlocal elicit_result + elicit_result = await ctx.elicit_url( + message="Authorize", + url="https://example.com/oauth", + elicitation_id="oauth-123", + ) + + async with anyio.create_task_group() as tg: + tg.start_soon(run_elicit_url) + + # Wait for request to be queued + await queue.wait_for_message(task.taskId) + + # Verify task is in input_required status + updated_task = await store.get_task(task.taskId) + assert updated_task is not None + assert updated_task.status == "input_required" + + # Dequeue and simulate response + msg = await queue.dequeue(task.taskId) + assert msg is not None + assert msg.resolver is not None + + # Resolve with mock elicitation response (URL mode just returns action) + msg.resolver.set_result({"action": "accept"}) + + # Verify result + assert elicit_result is not None + assert elicit_result.action == "accept" + + # Verify task is back to working + final_task = await store.get_task(task.taskId) + assert final_task is not None + assert final_task.status == "working" + + store.cleanup() + + +@pytest.mark.anyio +async def test_create_message_queues_request_and_waits_for_response() -> None: + """Test that create_message() queues request and waits for response.""" + store = InMemoryTaskStore() + queue = InMemoryTaskMessageQueue() + handler = TaskResultHandler(store, queue) + task = await store.create_task(TaskMetadata(ttl=60000)) + + mock_session = Mock() + mock_session.check_client_capability = Mock(return_value=True) + mock_session._build_create_message_request = Mock( + return_value=JSONRPCRequest( + jsonrpc="2.0", + id="test-req-2", + method="sampling/createMessage", + params={"messages": [], "maxTokens": 100, "_meta": {}}, + ) + ) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + handler=handler, + ) + + sampling_result = None + + async def run_sampling() -> None: + nonlocal sampling_result + sampling_result = await ctx.create_message( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))], + max_tokens=100, + ) + + async with anyio.create_task_group() as tg: + tg.start_soon(run_sampling) + + # Wait for request to be queued + await queue.wait_for_message(task.taskId) + + # Verify task is in input_required status + updated_task = await store.get_task(task.taskId) + assert updated_task is not None + assert updated_task.status == "input_required" + + # Dequeue and simulate response + msg = await queue.dequeue(task.taskId) + assert msg is not None + assert msg.resolver is not None + + # Resolve with mock sampling response + msg.resolver.set_result( + { + "role": "assistant", + "content": {"type": "text", "text": "Hello back!"}, + "model": "test-model", + "stopReason": "endTurn", + } + ) + + # Verify result + assert sampling_result is not None + assert sampling_result.role == "assistant" + assert sampling_result.model == "test-model" + + # Verify task is back to working + final_task = await store.get_task(task.taskId) + assert final_task is not None + assert final_task.status == "working" + + store.cleanup() + + +@pytest.mark.anyio +async def test_elicit_restores_status_on_cancellation() -> None: + """Test that elicit() restores task status to working when cancelled.""" + store = InMemoryTaskStore() + queue = InMemoryTaskMessageQueue() + handler = TaskResultHandler(store, queue) + task = await store.create_task(TaskMetadata(ttl=60000)) + + mock_session = Mock() + mock_session.check_client_capability = Mock(return_value=True) + mock_session._build_elicit_form_request = Mock( + return_value=JSONRPCRequest( + jsonrpc="2.0", + id="test-req-cancel", + method="elicitation/create", + params={"message": "Test?", "_meta": {}}, + ) + ) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + handler=handler, + ) + + cancelled_error_raised = False + + async with anyio.create_task_group() as tg: + + async def do_elicit() -> None: + nonlocal cancelled_error_raised + try: + await ctx.elicit( + message="Test?", + requestedSchema={"type": "object"}, + ) + except anyio.get_cancelled_exc_class(): + cancelled_error_raised = True + # Don't re-raise - let the test continue + + tg.start_soon(do_elicit) + + # Wait for request to be queued + await queue.wait_for_message(task.taskId) + + # Verify task is in input_required status + updated_task = await store.get_task(task.taskId) + assert updated_task is not None + assert updated_task.status == "input_required" + + # Get the queued message and set cancellation exception on its resolver + msg = await queue.dequeue(task.taskId) + assert msg is not None + assert msg.resolver is not None + + # Trigger cancellation by setting exception (use asyncio.CancelledError directly) + msg.resolver.set_exception(asyncio.CancelledError()) + + # Verify task is back to working after cancellation + final_task = await store.get_task(task.taskId) + assert final_task is not None + assert final_task.status == "working" + assert cancelled_error_raised + + store.cleanup() + + +@pytest.mark.anyio +async def test_create_message_restores_status_on_cancellation() -> None: + """Test that create_message() restores task status to working when cancelled.""" + store = InMemoryTaskStore() + queue = InMemoryTaskMessageQueue() + handler = TaskResultHandler(store, queue) + task = await store.create_task(TaskMetadata(ttl=60000)) + + mock_session = Mock() + mock_session.check_client_capability = Mock(return_value=True) + mock_session._build_create_message_request = Mock( + return_value=JSONRPCRequest( + jsonrpc="2.0", + id="test-req-cancel-2", + method="sampling/createMessage", + params={"messages": [], "maxTokens": 100, "_meta": {}}, + ) + ) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + handler=handler, + ) + + cancelled_error_raised = False + + async with anyio.create_task_group() as tg: + + async def do_sampling() -> None: + nonlocal cancelled_error_raised + try: + await ctx.create_message( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))], + max_tokens=100, + ) + except anyio.get_cancelled_exc_class(): + cancelled_error_raised = True + # Don't re-raise + + tg.start_soon(do_sampling) + + # Wait for request to be queued + await queue.wait_for_message(task.taskId) + + # Verify task is in input_required status + updated_task = await store.get_task(task.taskId) + assert updated_task is not None + assert updated_task.status == "input_required" + + # Get the queued message and set cancellation exception on its resolver + msg = await queue.dequeue(task.taskId) + assert msg is not None + assert msg.resolver is not None + + # Trigger cancellation by setting exception (use asyncio.CancelledError directly) + msg.resolver.set_exception(asyncio.CancelledError()) + + # Verify task is back to working after cancellation + final_task = await store.get_task(task.taskId) + assert final_task is not None + assert final_task.status == "working" + assert cancelled_error_raised + + store.cleanup() + + +@pytest.mark.anyio +async def test_elicit_as_task_raises_without_handler() -> None: + """Test that elicit_as_task() raises when handler is not provided.""" + store = InMemoryTaskStore() + queue = InMemoryTaskMessageQueue() + task = await store.create_task(TaskMetadata(ttl=60000)) + + # Create mock session with proper client capabilities + mock_session = Mock() + mock_session.client_params = InitializeRequestParams( + protocolVersion="2025-01-01", + capabilities=ClientCapabilities( + tasks=ClientTasksCapability( + requests=ClientTasksRequestsCapability( + elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability()) + ) + ) + ), + clientInfo=Implementation(name="test", version="1.0"), + ) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + handler=None, + ) + + with pytest.raises(RuntimeError, match="handler is required for elicit_as_task"): + await ctx.elicit_as_task(message="Test?", requestedSchema={"type": "object"}) + + store.cleanup() + + +@pytest.mark.anyio +async def test_create_message_as_task_raises_without_handler() -> None: + """Test that create_message_as_task() raises when handler is not provided.""" + store = InMemoryTaskStore() + queue = InMemoryTaskMessageQueue() + task = await store.create_task(TaskMetadata(ttl=60000)) + + # Create mock session with proper client capabilities + mock_session = Mock() + mock_session.client_params = InitializeRequestParams( + protocolVersion="2025-01-01", + capabilities=ClientCapabilities( + tasks=ClientTasksCapability( + requests=ClientTasksRequestsCapability( + sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()) + ) + ) + ), + clientInfo=Implementation(name="test", version="1.0"), + ) + + ctx = ServerTaskContext( + task=task, + store=store, + session=mock_session, + queue=queue, + handler=None, + ) + + with pytest.raises(RuntimeError, match="handler is required for create_message_as_task"): + await ctx.create_message_as_task( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))], + max_tokens=100, + ) + + store.cleanup() diff --git a/tests/experimental/tasks/server/test_store.py b/tests/experimental/tasks/server/test_store.py new file mode 100644 index 0000000000..2eac31dfe6 --- /dev/null +++ b/tests/experimental/tasks/server/test_store.py @@ -0,0 +1,406 @@ +"""Tests for InMemoryTaskStore.""" + +from collections.abc import AsyncIterator +from datetime import datetime, timedelta, timezone + +import pytest + +from mcp.shared.exceptions import McpError +from mcp.shared.experimental.tasks.helpers import cancel_task +from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore +from mcp.types import INVALID_PARAMS, CallToolResult, TaskMetadata, TextContent + + +@pytest.fixture +async def store() -> AsyncIterator[InMemoryTaskStore]: + """Provide a clean InMemoryTaskStore for each test with automatic cleanup.""" + store = InMemoryTaskStore() + yield store + store.cleanup() + + +@pytest.mark.anyio +async def test_create_and_get(store: InMemoryTaskStore) -> None: + """Test InMemoryTaskStore create and get operations.""" + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + + assert task.taskId is not None + assert task.status == "working" + assert task.ttl == 60000 + + retrieved = await store.get_task(task.taskId) + assert retrieved is not None + assert retrieved.taskId == task.taskId + assert retrieved.status == "working" + + +@pytest.mark.anyio +async def test_create_with_custom_id(store: InMemoryTaskStore) -> None: + """Test InMemoryTaskStore create with custom task ID.""" + task = await store.create_task( + metadata=TaskMetadata(ttl=60000), + task_id="my-custom-id", + ) + + assert task.taskId == "my-custom-id" + assert task.status == "working" + + retrieved = await store.get_task("my-custom-id") + assert retrieved is not None + assert retrieved.taskId == "my-custom-id" + + +@pytest.mark.anyio +async def test_create_duplicate_id_raises(store: InMemoryTaskStore) -> None: + """Test that creating a task with duplicate ID raises.""" + await store.create_task(metadata=TaskMetadata(ttl=60000), task_id="duplicate") + + with pytest.raises(ValueError, match="already exists"): + await store.create_task(metadata=TaskMetadata(ttl=60000), task_id="duplicate") + + +@pytest.mark.anyio +async def test_get_nonexistent_returns_none(store: InMemoryTaskStore) -> None: + """Test that getting a nonexistent task returns None.""" + retrieved = await store.get_task("nonexistent") + assert retrieved is None + + +@pytest.mark.anyio +async def test_update_status(store: InMemoryTaskStore) -> None: + """Test InMemoryTaskStore status updates.""" + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + + updated = await store.update_task(task.taskId, status="completed", status_message="All done!") + + assert updated.status == "completed" + assert updated.statusMessage == "All done!" + + retrieved = await store.get_task(task.taskId) + assert retrieved is not None + assert retrieved.status == "completed" + assert retrieved.statusMessage == "All done!" + + +@pytest.mark.anyio +async def test_update_nonexistent_raises(store: InMemoryTaskStore) -> None: + """Test that updating a nonexistent task raises.""" + with pytest.raises(ValueError, match="not found"): + await store.update_task("nonexistent", status="completed") + + +@pytest.mark.anyio +async def test_store_and_get_result(store: InMemoryTaskStore) -> None: + """Test InMemoryTaskStore result storage and retrieval.""" + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + + # Store result + result = CallToolResult(content=[TextContent(type="text", text="Result data")]) + await store.store_result(task.taskId, result) + + # Retrieve result + retrieved_result = await store.get_result(task.taskId) + assert retrieved_result == result + + +@pytest.mark.anyio +async def test_get_result_nonexistent_returns_none(store: InMemoryTaskStore) -> None: + """Test that getting result for nonexistent task returns None.""" + result = await store.get_result("nonexistent") + assert result is None + + +@pytest.mark.anyio +async def test_get_result_no_result_returns_none(store: InMemoryTaskStore) -> None: + """Test that getting result when none stored returns None.""" + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + result = await store.get_result(task.taskId) + assert result is None + + +@pytest.mark.anyio +async def test_list_tasks(store: InMemoryTaskStore) -> None: + """Test InMemoryTaskStore list operation.""" + # Create multiple tasks + for _ in range(3): + await store.create_task(metadata=TaskMetadata(ttl=60000)) + + tasks, next_cursor = await store.list_tasks() + assert len(tasks) == 3 + assert next_cursor is None # Less than page size + + +@pytest.mark.anyio +async def test_list_tasks_pagination() -> None: + """Test InMemoryTaskStore pagination.""" + # Needs custom page_size, can't use fixture + store = InMemoryTaskStore(page_size=2) + + # Create 5 tasks + for _ in range(5): + await store.create_task(metadata=TaskMetadata(ttl=60000)) + + # First page + tasks, next_cursor = await store.list_tasks() + assert len(tasks) == 2 + assert next_cursor is not None + + # Second page + tasks, next_cursor = await store.list_tasks(cursor=next_cursor) + assert len(tasks) == 2 + assert next_cursor is not None + + # Third page (last) + tasks, next_cursor = await store.list_tasks(cursor=next_cursor) + assert len(tasks) == 1 + assert next_cursor is None + + store.cleanup() + + +@pytest.mark.anyio +async def test_list_tasks_invalid_cursor(store: InMemoryTaskStore) -> None: + """Test that invalid cursor raises.""" + await store.create_task(metadata=TaskMetadata(ttl=60000)) + + with pytest.raises(ValueError, match="Invalid cursor"): + await store.list_tasks(cursor="invalid-cursor") + + +@pytest.mark.anyio +async def test_delete_task(store: InMemoryTaskStore) -> None: + """Test InMemoryTaskStore delete operation.""" + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + + deleted = await store.delete_task(task.taskId) + assert deleted is True + + retrieved = await store.get_task(task.taskId) + assert retrieved is None + + # Delete non-existent + deleted = await store.delete_task(task.taskId) + assert deleted is False + + +@pytest.mark.anyio +async def test_get_all_tasks_helper(store: InMemoryTaskStore) -> None: + """Test the get_all_tasks debugging helper.""" + await store.create_task(metadata=TaskMetadata(ttl=60000)) + await store.create_task(metadata=TaskMetadata(ttl=60000)) + + all_tasks = store.get_all_tasks() + assert len(all_tasks) == 2 + + +@pytest.mark.anyio +async def test_store_result_nonexistent_raises(store: InMemoryTaskStore) -> None: + """Test that storing result for nonexistent task raises ValueError.""" + result = CallToolResult(content=[TextContent(type="text", text="Result")]) + + with pytest.raises(ValueError, match="not found"): + await store.store_result("nonexistent-id", result) + + +@pytest.mark.anyio +async def test_create_task_with_null_ttl(store: InMemoryTaskStore) -> None: + """Test creating task with null TTL (never expires).""" + task = await store.create_task(metadata=TaskMetadata(ttl=None)) + + assert task.ttl is None + + # Task should persist (not expire) + retrieved = await store.get_task(task.taskId) + assert retrieved is not None + + +@pytest.mark.anyio +async def test_task_expiration_cleanup(store: InMemoryTaskStore) -> None: + """Test that expired tasks are cleaned up lazily.""" + # Create a task with very short TTL + task = await store.create_task(metadata=TaskMetadata(ttl=1)) # 1ms TTL + + # Manually force the expiry to be in the past + stored = store._tasks.get(task.taskId) + assert stored is not None + stored.expires_at = datetime.now(timezone.utc) - timedelta(seconds=10) + + # Task should still exist in internal dict but be expired + assert task.taskId in store._tasks + + # Any access operation should clean up expired tasks + # list_tasks triggers cleanup + tasks, _ = await store.list_tasks() + + # Expired task should be cleaned up + assert task.taskId not in store._tasks + assert len(tasks) == 0 + + +@pytest.mark.anyio +async def test_task_with_null_ttl_never_expires(store: InMemoryTaskStore) -> None: + """Test that tasks with null TTL never expire during cleanup.""" + # Create task with null TTL + task = await store.create_task(metadata=TaskMetadata(ttl=None)) + + # Verify internal storage has no expiry + stored = store._tasks.get(task.taskId) + assert stored is not None + assert stored.expires_at is None + + # Access operations should NOT remove this task + await store.list_tasks() + await store.get_task(task.taskId) + + # Task should still exist + assert task.taskId in store._tasks + retrieved = await store.get_task(task.taskId) + assert retrieved is not None + + +@pytest.mark.anyio +async def test_terminal_task_ttl_reset(store: InMemoryTaskStore) -> None: + """Test that TTL is reset when task enters terminal state.""" + # Create task with short TTL + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) # 60s + + # Get the initial expiry + stored = store._tasks.get(task.taskId) + assert stored is not None + initial_expiry = stored.expires_at + assert initial_expiry is not None + + # Update to terminal state (completed) + await store.update_task(task.taskId, status="completed") + + # Expiry should be reset to a new time (from now + TTL) + new_expiry = stored.expires_at + assert new_expiry is not None + assert new_expiry >= initial_expiry + + +@pytest.mark.anyio +async def test_terminal_status_transition_rejected(store: InMemoryTaskStore) -> None: + """Test that transitions from terminal states are rejected. + + Per spec: Terminal states (completed, failed, cancelled) MUST NOT + transition to any other status. + """ + # Test each terminal status + for terminal_status in ("completed", "failed", "cancelled"): + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + + # Move to terminal state + await store.update_task(task.taskId, status=terminal_status) + + # Attempting to transition to any other status should raise + with pytest.raises(ValueError, match="Cannot transition from terminal status"): + await store.update_task(task.taskId, status="working") + + # Also test transitioning to another terminal state + other_terminal = "failed" if terminal_status != "failed" else "completed" + with pytest.raises(ValueError, match="Cannot transition from terminal status"): + await store.update_task(task.taskId, status=other_terminal) + + +@pytest.mark.anyio +async def test_terminal_status_allows_same_status(store: InMemoryTaskStore) -> None: + """Test that setting the same terminal status doesn't raise. + + This is not a transition, so it should be allowed (no-op). + """ + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + await store.update_task(task.taskId, status="completed") + + # Setting the same status should not raise + updated = await store.update_task(task.taskId, status="completed") + assert updated.status == "completed" + + # Updating just the message should also work + updated = await store.update_task(task.taskId, status_message="Updated message") + assert updated.statusMessage == "Updated message" + + +@pytest.mark.anyio +async def test_wait_for_update_nonexistent_raises(store: InMemoryTaskStore) -> None: + """Test that wait_for_update raises for nonexistent task.""" + with pytest.raises(ValueError, match="not found"): + await store.wait_for_update("nonexistent-task-id") + + +@pytest.mark.anyio +async def test_cancel_task_succeeds_for_working_task(store: InMemoryTaskStore) -> None: + """Test cancel_task helper succeeds for a working task.""" + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + assert task.status == "working" + + result = await cancel_task(store, task.taskId) + + assert result.taskId == task.taskId + assert result.status == "cancelled" + + # Verify store is updated + retrieved = await store.get_task(task.taskId) + assert retrieved is not None + assert retrieved.status == "cancelled" + + +@pytest.mark.anyio +async def test_cancel_task_rejects_nonexistent_task(store: InMemoryTaskStore) -> None: + """Test cancel_task raises McpError with INVALID_PARAMS for nonexistent task.""" + with pytest.raises(McpError) as exc_info: + await cancel_task(store, "nonexistent-task-id") + + assert exc_info.value.error.code == INVALID_PARAMS + assert "not found" in exc_info.value.error.message + + +@pytest.mark.anyio +async def test_cancel_task_rejects_completed_task(store: InMemoryTaskStore) -> None: + """Test cancel_task raises McpError with INVALID_PARAMS for completed task.""" + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + await store.update_task(task.taskId, status="completed") + + with pytest.raises(McpError) as exc_info: + await cancel_task(store, task.taskId) + + assert exc_info.value.error.code == INVALID_PARAMS + assert "terminal state 'completed'" in exc_info.value.error.message + + +@pytest.mark.anyio +async def test_cancel_task_rejects_failed_task(store: InMemoryTaskStore) -> None: + """Test cancel_task raises McpError with INVALID_PARAMS for failed task.""" + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + await store.update_task(task.taskId, status="failed") + + with pytest.raises(McpError) as exc_info: + await cancel_task(store, task.taskId) + + assert exc_info.value.error.code == INVALID_PARAMS + assert "terminal state 'failed'" in exc_info.value.error.message + + +@pytest.mark.anyio +async def test_cancel_task_rejects_already_cancelled_task(store: InMemoryTaskStore) -> None: + """Test cancel_task raises McpError with INVALID_PARAMS for already cancelled task.""" + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + await store.update_task(task.taskId, status="cancelled") + + with pytest.raises(McpError) as exc_info: + await cancel_task(store, task.taskId) + + assert exc_info.value.error.code == INVALID_PARAMS + assert "terminal state 'cancelled'" in exc_info.value.error.message + + +@pytest.mark.anyio +async def test_cancel_task_succeeds_for_input_required_task(store: InMemoryTaskStore) -> None: + """Test cancel_task helper succeeds for a task in input_required status.""" + task = await store.create_task(metadata=TaskMetadata(ttl=60000)) + await store.update_task(task.taskId, status="input_required") + + result = await cancel_task(store, task.taskId) + + assert result.taskId == task.taskId + assert result.status == "cancelled" diff --git a/tests/experimental/tasks/server/test_task_result_handler.py b/tests/experimental/tasks/server/test_task_result_handler.py new file mode 100644 index 0000000000..db5b9edc70 --- /dev/null +++ b/tests/experimental/tasks/server/test_task_result_handler.py @@ -0,0 +1,354 @@ +"""Tests for TaskResultHandler.""" + +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import AsyncMock, Mock + +import anyio +import pytest + +from mcp.server.experimental.task_result_handler import TaskResultHandler +from mcp.shared.exceptions import McpError +from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore +from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, QueuedMessage +from mcp.shared.experimental.tasks.resolver import Resolver +from mcp.shared.message import SessionMessage +from mcp.types import ( + INVALID_REQUEST, + CallToolResult, + ErrorData, + GetTaskPayloadRequest, + GetTaskPayloadRequestParams, + GetTaskPayloadResult, + JSONRPCRequest, + TaskMetadata, + TextContent, +) + + +@pytest.fixture +async def store() -> AsyncIterator[InMemoryTaskStore]: + """Provide a clean store for each test.""" + s = InMemoryTaskStore() + yield s + s.cleanup() + + +@pytest.fixture +def queue() -> InMemoryTaskMessageQueue: + """Provide a clean queue for each test.""" + return InMemoryTaskMessageQueue() + + +@pytest.fixture +def handler(store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue) -> TaskResultHandler: + """Provide a handler for each test.""" + return TaskResultHandler(store, queue) + + +@pytest.mark.anyio +async def test_handle_returns_result_for_completed_task( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """Test that handle() returns the stored result for a completed task.""" + task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task") + result = CallToolResult(content=[TextContent(type="text", text="Done!")]) + await store.store_result(task.taskId, result) + await store.update_task(task.taskId, status="completed") + + mock_session = Mock() + mock_session.send_message = AsyncMock() + + request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task.taskId)) + response = await handler.handle(request, mock_session, "req-1") + + assert response is not None + assert response.meta is not None + assert "io.modelcontextprotocol/related-task" in response.meta + + +@pytest.mark.anyio +async def test_handle_raises_for_nonexistent_task( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """Test that handle() raises McpError for nonexistent task.""" + mock_session = Mock() + request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId="nonexistent")) + + with pytest.raises(McpError) as exc_info: + await handler.handle(request, mock_session, "req-1") + + assert "not found" in exc_info.value.error.message + + +@pytest.mark.anyio +async def test_handle_returns_empty_result_when_no_result_stored( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """Test that handle() returns minimal result when task completed without stored result.""" + task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task") + await store.update_task(task.taskId, status="completed") + + mock_session = Mock() + mock_session.send_message = AsyncMock() + + request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task.taskId)) + response = await handler.handle(request, mock_session, "req-1") + + assert response is not None + assert response.meta is not None + assert "io.modelcontextprotocol/related-task" in response.meta + + +@pytest.mark.anyio +async def test_handle_delivers_queued_messages( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """Test that handle() delivers queued messages before returning.""" + task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task") + + queued_msg = QueuedMessage( + type="notification", + message=JSONRPCRequest( + jsonrpc="2.0", + id="notif-1", + method="test/notification", + params={}, + ), + ) + await queue.enqueue(task.taskId, queued_msg) + await store.update_task(task.taskId, status="completed") + + sent_messages: list[SessionMessage] = [] + + async def track_send(msg: SessionMessage) -> None: + sent_messages.append(msg) + + mock_session = Mock() + mock_session.send_message = track_send + + request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task.taskId)) + await handler.handle(request, mock_session, "req-1") + + assert len(sent_messages) == 1 + + +@pytest.mark.anyio +async def test_handle_waits_for_task_completion( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """Test that handle() waits for task to complete before returning.""" + task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task") + + mock_session = Mock() + mock_session.send_message = AsyncMock() + + request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task.taskId)) + result_holder: list[GetTaskPayloadResult | None] = [None] + + async def run_handle() -> None: + result_holder[0] = await handler.handle(request, mock_session, "req-1") + + async with anyio.create_task_group() as tg: + tg.start_soon(run_handle) + + # Wait for handler to start waiting (event gets created when wait starts) + while task.taskId not in store._update_events: + await anyio.sleep(0) + + await store.store_result(task.taskId, CallToolResult(content=[TextContent(type="text", text="Done")])) + await store.update_task(task.taskId, status="completed") + + assert result_holder[0] is not None + + +@pytest.mark.anyio +async def test_route_response_resolves_pending_request( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """Test that route_response() resolves a pending request.""" + resolver: Resolver[dict[str, Any]] = Resolver() + handler._pending_requests["req-123"] = resolver + + result = handler.route_response("req-123", {"status": "ok"}) + + assert result is True + assert resolver.done() + assert await resolver.wait() == {"status": "ok"} + + +@pytest.mark.anyio +async def test_route_response_returns_false_for_unknown_request( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """Test that route_response() returns False for unknown request ID.""" + result = handler.route_response("unknown-req", {"status": "ok"}) + assert result is False + + +@pytest.mark.anyio +async def test_route_response_returns_false_for_already_done_resolver( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """Test that route_response() returns False if resolver already completed.""" + resolver: Resolver[dict[str, Any]] = Resolver() + resolver.set_result({"already": "done"}) + handler._pending_requests["req-123"] = resolver + + result = handler.route_response("req-123", {"new": "data"}) + + assert result is False + + +@pytest.mark.anyio +async def test_route_error_resolves_pending_request_with_exception( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """Test that route_error() sets exception on pending request.""" + resolver: Resolver[dict[str, Any]] = Resolver() + handler._pending_requests["req-123"] = resolver + + error = ErrorData(code=INVALID_REQUEST, message="Something went wrong") + result = handler.route_error("req-123", error) + + assert result is True + assert resolver.done() + + with pytest.raises(McpError) as exc_info: + await resolver.wait() + assert exc_info.value.error.message == "Something went wrong" + + +@pytest.mark.anyio +async def test_route_error_returns_false_for_unknown_request( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """Test that route_error() returns False for unknown request ID.""" + error = ErrorData(code=INVALID_REQUEST, message="Error") + result = handler.route_error("unknown-req", error) + assert result is False + + +@pytest.mark.anyio +async def test_deliver_registers_resolver_for_request_messages( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """Test that _deliver_queued_messages registers resolvers for request messages.""" + task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task") + + resolver: Resolver[dict[str, Any]] = Resolver() + queued_msg = QueuedMessage( + type="request", + message=JSONRPCRequest( + jsonrpc="2.0", + id="inner-req-1", + method="elicitation/create", + params={}, + ), + resolver=resolver, + original_request_id="inner-req-1", + ) + await queue.enqueue(task.taskId, queued_msg) + + mock_session = Mock() + mock_session.send_message = AsyncMock() + + await handler._deliver_queued_messages(task.taskId, mock_session, "outer-req-1") + + assert "inner-req-1" in handler._pending_requests + assert handler._pending_requests["inner-req-1"] is resolver + + +@pytest.mark.anyio +async def test_deliver_skips_resolver_registration_when_no_original_id( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """Test that _deliver_queued_messages skips resolver registration when original_request_id is None.""" + task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task") + + resolver: Resolver[dict[str, Any]] = Resolver() + queued_msg = QueuedMessage( + type="request", + message=JSONRPCRequest( + jsonrpc="2.0", + id="inner-req-1", + method="elicitation/create", + params={}, + ), + resolver=resolver, + original_request_id=None, # No original request ID + ) + await queue.enqueue(task.taskId, queued_msg) + + mock_session = Mock() + mock_session.send_message = AsyncMock() + + await handler._deliver_queued_messages(task.taskId, mock_session, "outer-req-1") + + # Resolver should NOT be registered since original_request_id is None + assert len(handler._pending_requests) == 0 + # But the message should still be sent + mock_session.send_message.assert_called_once() + + +@pytest.mark.anyio +async def test_wait_for_task_update_handles_store_exception( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """Test that _wait_for_task_update handles store exception gracefully.""" + task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task") + + # Make wait_for_update raise an exception + async def failing_wait(task_id: str) -> None: + raise RuntimeError("Store error") + + store.wait_for_update = failing_wait # type: ignore[method-assign] + + # Queue a message to unblock the race via the queue path + async def enqueue_later() -> None: + # Wait for queue to start waiting (event gets created when wait starts) + while task.taskId not in queue._events: + await anyio.sleep(0) + await queue.enqueue( + task.taskId, + QueuedMessage( + type="notification", + message=JSONRPCRequest( + jsonrpc="2.0", + id="notif-1", + method="test/notification", + params={}, + ), + ), + ) + + async with anyio.create_task_group() as tg: + tg.start_soon(enqueue_later) + # This should complete via the queue path even though store raises + await handler._wait_for_task_update(task.taskId) + + +@pytest.mark.anyio +async def test_wait_for_task_update_handles_queue_exception( + store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler +) -> None: + """Test that _wait_for_task_update handles queue exception gracefully.""" + task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task") + + # Make wait_for_message raise an exception + async def failing_wait(task_id: str) -> None: + raise RuntimeError("Queue error") + + queue.wait_for_message = failing_wait # type: ignore[method-assign] + + # Update the store to unblock the race via the store path + async def update_later() -> None: + # Wait for store to start waiting (event gets created when wait starts) + while task.taskId not in store._update_events: + await anyio.sleep(0) + await store.update_task(task.taskId, status="completed") + + async with anyio.create_task_group() as tg: + tg.start_soon(update_later) + # This should complete via the store path even though queue raises + await handler._wait_for_task_update(task.taskId) diff --git a/tests/experimental/tasks/test_capabilities.py b/tests/experimental/tasks/test_capabilities.py new file mode 100644 index 0000000000..e78f16fe3f --- /dev/null +++ b/tests/experimental/tasks/test_capabilities.py @@ -0,0 +1,283 @@ +"""Tests for tasks capability checking utilities.""" + +import pytest + +from mcp.shared.exceptions import McpError +from mcp.shared.experimental.tasks.capabilities import ( + check_tasks_capability, + has_task_augmented_elicitation, + has_task_augmented_sampling, + require_task_augmented_elicitation, + require_task_augmented_sampling, +) +from mcp.types import ( + ClientCapabilities, + ClientTasksCapability, + ClientTasksRequestsCapability, + TasksCreateElicitationCapability, + TasksCreateMessageCapability, + TasksElicitationCapability, + TasksSamplingCapability, +) + + +class TestCheckTasksCapability: + """Tests for check_tasks_capability function.""" + + def test_required_requests_none_returns_true(self) -> None: + """When required.requests is None, should return True.""" + required = ClientTasksCapability() + client = ClientTasksCapability() + assert check_tasks_capability(required, client) is True + + def test_client_requests_none_returns_false(self) -> None: + """When client.requests is None but required.requests is set, should return False.""" + required = ClientTasksCapability(requests=ClientTasksRequestsCapability()) + client = ClientTasksCapability() + assert check_tasks_capability(required, client) is False + + def test_elicitation_required_but_client_missing(self) -> None: + """When elicitation is required but client doesn't have it.""" + required = ClientTasksCapability( + requests=ClientTasksRequestsCapability(elicitation=TasksElicitationCapability()) + ) + client = ClientTasksCapability(requests=ClientTasksRequestsCapability()) + assert check_tasks_capability(required, client) is False + + def test_elicitation_create_required_but_client_missing(self) -> None: + """When elicitation.create is required but client doesn't have it.""" + required = ClientTasksCapability( + requests=ClientTasksRequestsCapability( + elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability()) + ) + ) + client = ClientTasksCapability( + requests=ClientTasksRequestsCapability( + elicitation=TasksElicitationCapability() # No create + ) + ) + assert check_tasks_capability(required, client) is False + + def test_elicitation_create_present(self) -> None: + """When elicitation.create is required and client has it.""" + required = ClientTasksCapability( + requests=ClientTasksRequestsCapability( + elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability()) + ) + ) + client = ClientTasksCapability( + requests=ClientTasksRequestsCapability( + elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability()) + ) + ) + assert check_tasks_capability(required, client) is True + + def test_sampling_required_but_client_missing(self) -> None: + """When sampling is required but client doesn't have it.""" + required = ClientTasksCapability(requests=ClientTasksRequestsCapability(sampling=TasksSamplingCapability())) + client = ClientTasksCapability(requests=ClientTasksRequestsCapability()) + assert check_tasks_capability(required, client) is False + + def test_sampling_create_message_required_but_client_missing(self) -> None: + """When sampling.createMessage is required but client doesn't have it.""" + required = ClientTasksCapability( + requests=ClientTasksRequestsCapability( + sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()) + ) + ) + client = ClientTasksCapability( + requests=ClientTasksRequestsCapability( + sampling=TasksSamplingCapability() # No createMessage + ) + ) + assert check_tasks_capability(required, client) is False + + def test_sampling_create_message_present(self) -> None: + """When sampling.createMessage is required and client has it.""" + required = ClientTasksCapability( + requests=ClientTasksRequestsCapability( + sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()) + ) + ) + client = ClientTasksCapability( + requests=ClientTasksRequestsCapability( + sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()) + ) + ) + assert check_tasks_capability(required, client) is True + + def test_both_elicitation_and_sampling_present(self) -> None: + """When both elicitation.create and sampling.createMessage are required and client has both.""" + required = ClientTasksCapability( + requests=ClientTasksRequestsCapability( + elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability()), + sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()), + ) + ) + client = ClientTasksCapability( + requests=ClientTasksRequestsCapability( + elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability()), + sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()), + ) + ) + assert check_tasks_capability(required, client) is True + + def test_elicitation_without_create_required(self) -> None: + """When elicitation is required but not create specifically.""" + required = ClientTasksCapability( + requests=ClientTasksRequestsCapability( + elicitation=TasksElicitationCapability() # No create + ) + ) + client = ClientTasksCapability( + requests=ClientTasksRequestsCapability( + elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability()) + ) + ) + assert check_tasks_capability(required, client) is True + + def test_sampling_without_create_message_required(self) -> None: + """When sampling is required but not createMessage specifically.""" + required = ClientTasksCapability( + requests=ClientTasksRequestsCapability( + sampling=TasksSamplingCapability() # No createMessage + ) + ) + client = ClientTasksCapability( + requests=ClientTasksRequestsCapability( + sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()) + ) + ) + assert check_tasks_capability(required, client) is True + + +class TestHasTaskAugmentedElicitation: + """Tests for has_task_augmented_elicitation function.""" + + def test_tasks_none(self) -> None: + """Returns False when caps.tasks is None.""" + caps = ClientCapabilities() + assert has_task_augmented_elicitation(caps) is False + + def test_requests_none(self) -> None: + """Returns False when caps.tasks.requests is None.""" + caps = ClientCapabilities(tasks=ClientTasksCapability()) + assert has_task_augmented_elicitation(caps) is False + + def test_elicitation_none(self) -> None: + """Returns False when caps.tasks.requests.elicitation is None.""" + caps = ClientCapabilities(tasks=ClientTasksCapability(requests=ClientTasksRequestsCapability())) + assert has_task_augmented_elicitation(caps) is False + + def test_create_none(self) -> None: + """Returns False when caps.tasks.requests.elicitation.create is None.""" + caps = ClientCapabilities( + tasks=ClientTasksCapability( + requests=ClientTasksRequestsCapability(elicitation=TasksElicitationCapability()) + ) + ) + assert has_task_augmented_elicitation(caps) is False + + def test_create_present(self) -> None: + """Returns True when full capability path is present.""" + caps = ClientCapabilities( + tasks=ClientTasksCapability( + requests=ClientTasksRequestsCapability( + elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability()) + ) + ) + ) + assert has_task_augmented_elicitation(caps) is True + + +class TestHasTaskAugmentedSampling: + """Tests for has_task_augmented_sampling function.""" + + def test_tasks_none(self) -> None: + """Returns False when caps.tasks is None.""" + caps = ClientCapabilities() + assert has_task_augmented_sampling(caps) is False + + def test_requests_none(self) -> None: + """Returns False when caps.tasks.requests is None.""" + caps = ClientCapabilities(tasks=ClientTasksCapability()) + assert has_task_augmented_sampling(caps) is False + + def test_sampling_none(self) -> None: + """Returns False when caps.tasks.requests.sampling is None.""" + caps = ClientCapabilities(tasks=ClientTasksCapability(requests=ClientTasksRequestsCapability())) + assert has_task_augmented_sampling(caps) is False + + def test_create_message_none(self) -> None: + """Returns False when caps.tasks.requests.sampling.createMessage is None.""" + caps = ClientCapabilities( + tasks=ClientTasksCapability(requests=ClientTasksRequestsCapability(sampling=TasksSamplingCapability())) + ) + assert has_task_augmented_sampling(caps) is False + + def test_create_message_present(self) -> None: + """Returns True when full capability path is present.""" + caps = ClientCapabilities( + tasks=ClientTasksCapability( + requests=ClientTasksRequestsCapability( + sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()) + ) + ) + ) + assert has_task_augmented_sampling(caps) is True + + +class TestRequireTaskAugmentedElicitation: + """Tests for require_task_augmented_elicitation function.""" + + def test_raises_when_none(self) -> None: + """Raises McpError when client_caps is None.""" + with pytest.raises(McpError) as exc_info: + require_task_augmented_elicitation(None) + assert "task-augmented elicitation" in str(exc_info.value) + + def test_raises_when_missing(self) -> None: + """Raises McpError when capability is missing.""" + caps = ClientCapabilities() + with pytest.raises(McpError) as exc_info: + require_task_augmented_elicitation(caps) + assert "task-augmented elicitation" in str(exc_info.value) + + def test_passes_when_present(self) -> None: + """Does not raise when capability is present.""" + caps = ClientCapabilities( + tasks=ClientTasksCapability( + requests=ClientTasksRequestsCapability( + elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability()) + ) + ) + ) + require_task_augmented_elicitation(caps) + + +class TestRequireTaskAugmentedSampling: + """Tests for require_task_augmented_sampling function.""" + + def test_raises_when_none(self) -> None: + """Raises McpError when client_caps is None.""" + with pytest.raises(McpError) as exc_info: + require_task_augmented_sampling(None) + assert "task-augmented sampling" in str(exc_info.value) + + def test_raises_when_missing(self) -> None: + """Raises McpError when capability is missing.""" + caps = ClientCapabilities() + with pytest.raises(McpError) as exc_info: + require_task_augmented_sampling(caps) + assert "task-augmented sampling" in str(exc_info.value) + + def test_passes_when_present(self) -> None: + """Does not raise when capability is present.""" + caps = ClientCapabilities( + tasks=ClientTasksCapability( + requests=ClientTasksRequestsCapability( + sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()) + ) + ) + ) + require_task_augmented_sampling(caps) diff --git a/tests/experimental/tasks/test_elicitation_scenarios.py b/tests/experimental/tasks/test_elicitation_scenarios.py new file mode 100644 index 0000000000..be2b616018 --- /dev/null +++ b/tests/experimental/tasks/test_elicitation_scenarios.py @@ -0,0 +1,737 @@ +""" +Tests for the four elicitation scenarios with tasks. + +This tests all combinations of tool call types and elicitation types: +1. Normal tool call + Normal elicitation (session.elicit) +2. Normal tool call + Task-augmented elicitation (session.experimental.elicit_as_task) +3. Task-augmented tool call + Normal elicitation (task.elicit) +4. Task-augmented tool call + Task-augmented elicitation (task.elicit_as_task) + +And the same for sampling (create_message). +""" + +from typing import Any + +import anyio +import pytest +from anyio import Event + +from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers +from mcp.client.session import ClientSession +from mcp.server import Server +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.server.lowlevel import NotificationOptions +from mcp.shared.context import RequestContext +from mcp.shared.experimental.tasks.helpers import is_terminal +from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore +from mcp.shared.message import SessionMessage +from mcp.types import ( + TASK_REQUIRED, + CallToolResult, + CreateMessageRequestParams, + CreateMessageResult, + CreateTaskResult, + ElicitRequestParams, + ElicitResult, + ErrorData, + GetTaskPayloadResult, + GetTaskResult, + SamplingMessage, + TaskMetadata, + TextContent, + Tool, + ToolExecution, +) + + +def create_client_task_handlers( + client_task_store: InMemoryTaskStore, + elicit_received: Event, +) -> ExperimentalTaskHandlers: + """Create task handlers for client to handle task-augmented elicitation from server.""" + + elicit_response = ElicitResult(action="accept", content={"confirm": True}) + task_complete_events: dict[str, Event] = {} + + async def handle_augmented_elicitation( + context: RequestContext[ClientSession, Any], + params: ElicitRequestParams, + task_metadata: TaskMetadata, + ) -> CreateTaskResult: + """Handle task-augmented elicitation by creating a client-side task.""" + elicit_received.set() + task = await client_task_store.create_task(task_metadata) + task_complete_events[task.taskId] = Event() + + async def complete_task() -> None: + # Store result before updating status to avoid race condition + await client_task_store.store_result(task.taskId, elicit_response) + await client_task_store.update_task(task.taskId, status="completed") + task_complete_events[task.taskId].set() + + context.session._task_group.start_soon(complete_task) # pyright: ignore[reportPrivateUsage] + return CreateTaskResult(task=task) + + async def handle_get_task( + context: RequestContext[ClientSession, Any], + params: Any, + ) -> GetTaskResult: + """Handle tasks/get from server.""" + task = await client_task_store.get_task(params.taskId) + assert task is not None, f"Task not found: {params.taskId}" + return GetTaskResult( + taskId=task.taskId, + status=task.status, + statusMessage=task.statusMessage, + createdAt=task.createdAt, + lastUpdatedAt=task.lastUpdatedAt, + ttl=task.ttl, + pollInterval=100, + ) + + async def handle_get_task_result( + context: RequestContext[ClientSession, Any], + params: Any, + ) -> GetTaskPayloadResult | ErrorData: + """Handle tasks/result from server.""" + event = task_complete_events.get(params.taskId) + assert event is not None, f"No completion event for task: {params.taskId}" + await event.wait() + result = await client_task_store.get_result(params.taskId) + assert result is not None, f"Result not found for task: {params.taskId}" + return GetTaskPayloadResult.model_validate(result.model_dump(by_alias=True)) + + return ExperimentalTaskHandlers( + augmented_elicitation=handle_augmented_elicitation, + get_task=handle_get_task, + get_task_result=handle_get_task_result, + ) + + +def create_sampling_task_handlers( + client_task_store: InMemoryTaskStore, + sampling_received: Event, +) -> ExperimentalTaskHandlers: + """Create task handlers for client to handle task-augmented sampling from server.""" + + sampling_response = CreateMessageResult( + role="assistant", + content=TextContent(type="text", text="Hello from the model!"), + model="test-model", + ) + task_complete_events: dict[str, Event] = {} + + async def handle_augmented_sampling( + context: RequestContext[ClientSession, Any], + params: CreateMessageRequestParams, + task_metadata: TaskMetadata, + ) -> CreateTaskResult: + """Handle task-augmented sampling by creating a client-side task.""" + sampling_received.set() + task = await client_task_store.create_task(task_metadata) + task_complete_events[task.taskId] = Event() + + async def complete_task() -> None: + # Store result before updating status to avoid race condition + await client_task_store.store_result(task.taskId, sampling_response) + await client_task_store.update_task(task.taskId, status="completed") + task_complete_events[task.taskId].set() + + context.session._task_group.start_soon(complete_task) # pyright: ignore[reportPrivateUsage] + return CreateTaskResult(task=task) + + async def handle_get_task( + context: RequestContext[ClientSession, Any], + params: Any, + ) -> GetTaskResult: + """Handle tasks/get from server.""" + task = await client_task_store.get_task(params.taskId) + assert task is not None, f"Task not found: {params.taskId}" + return GetTaskResult( + taskId=task.taskId, + status=task.status, + statusMessage=task.statusMessage, + createdAt=task.createdAt, + lastUpdatedAt=task.lastUpdatedAt, + ttl=task.ttl, + pollInterval=100, + ) + + async def handle_get_task_result( + context: RequestContext[ClientSession, Any], + params: Any, + ) -> GetTaskPayloadResult | ErrorData: + """Handle tasks/result from server.""" + event = task_complete_events.get(params.taskId) + assert event is not None, f"No completion event for task: {params.taskId}" + await event.wait() + result = await client_task_store.get_result(params.taskId) + assert result is not None, f"Result not found for task: {params.taskId}" + return GetTaskPayloadResult.model_validate(result.model_dump(by_alias=True)) + + return ExperimentalTaskHandlers( + augmented_sampling=handle_augmented_sampling, + get_task=handle_get_task, + get_task_result=handle_get_task_result, + ) + + +@pytest.mark.anyio +async def test_scenario1_normal_tool_normal_elicitation() -> None: + """ + Scenario 1: Normal tool call with normal elicitation. + + Server calls session.elicit() directly, client responds immediately. + """ + server = Server("test-scenario1") + elicit_received = Event() + tool_result: list[str] = [] + + @server.list_tools() + async def list_tools() -> list[Tool]: + return [ + Tool( + name="confirm_action", + description="Confirm an action", + inputSchema={"type": "object"}, + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult: + ctx = server.request_context + + # Normal elicitation - expects immediate response + result = await ctx.session.elicit( + message="Please confirm the action", + requestedSchema={"type": "object", "properties": {"confirm": {"type": "boolean"}}}, + ) + + confirmed = result.content.get("confirm", False) if result.content else False + tool_result.append("confirmed" if confirmed else "cancelled") + return CallToolResult(content=[TextContent(type="text", text="confirmed" if confirmed else "cancelled")]) + + # Elicitation callback for client + async def elicitation_callback( + context: RequestContext[ClientSession, Any], + params: ElicitRequestParams, + ) -> ElicitResult: + elicit_received.set() + return ElicitResult(action="accept", content={"confirm": True}) + + # Set up streams + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def run_server() -> None: + await server.run( + client_to_server_receive, + server_to_client_send, + server.create_initialization_options( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ) + + async def run_client() -> None: + async with ClientSession( + server_to_client_receive, + client_to_server_send, + elicitation_callback=elicitation_callback, + ) as client_session: + await client_session.initialize() + + # Call tool normally (not as task) + result = await client_session.call_tool("confirm_action", {}) + + # Verify elicitation was received and tool completed + assert elicit_received.is_set() + assert len(result.content) > 0 + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "confirmed" + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + tg.start_soon(run_client) + + assert tool_result[0] == "confirmed" + + +@pytest.mark.anyio +async def test_scenario2_normal_tool_task_augmented_elicitation() -> None: + """ + Scenario 2: Normal tool call with task-augmented elicitation. + + Server calls session.experimental.elicit_as_task(), client creates a task + for the elicitation and returns CreateTaskResult. Server polls client. + """ + server = Server("test-scenario2") + elicit_received = Event() + tool_result: list[str] = [] + + # Client-side task store for handling task-augmented elicitation + client_task_store = InMemoryTaskStore() + + @server.list_tools() + async def list_tools() -> list[Tool]: + return [ + Tool( + name="confirm_action", + description="Confirm an action", + inputSchema={"type": "object"}, + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult: + ctx = server.request_context + + # Task-augmented elicitation - server polls client + result = await ctx.session.experimental.elicit_as_task( + message="Please confirm the action", + requestedSchema={"type": "object", "properties": {"confirm": {"type": "boolean"}}}, + ttl=60000, + ) + + confirmed = result.content.get("confirm", False) if result.content else False + tool_result.append("confirmed" if confirmed else "cancelled") + return CallToolResult(content=[TextContent(type="text", text="confirmed" if confirmed else "cancelled")]) + + task_handlers = create_client_task_handlers(client_task_store, elicit_received) + + # Set up streams + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def run_server() -> None: + await server.run( + client_to_server_receive, + server_to_client_send, + server.create_initialization_options( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ) + + async def run_client() -> None: + async with ClientSession( + server_to_client_receive, + client_to_server_send, + experimental_task_handlers=task_handlers, + ) as client_session: + await client_session.initialize() + + # Call tool normally (not as task) + result = await client_session.call_tool("confirm_action", {}) + + # Verify elicitation was received and tool completed + assert elicit_received.is_set() + assert len(result.content) > 0 + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "confirmed" + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + tg.start_soon(run_client) + + assert tool_result[0] == "confirmed" + client_task_store.cleanup() + + +@pytest.mark.anyio +async def test_scenario3_task_augmented_tool_normal_elicitation() -> None: + """ + Scenario 3: Task-augmented tool call with normal elicitation. + + Client calls tool as task. Inside the task, server uses task.elicit() + which queues the request and delivers via tasks/result. + """ + server = Server("test-scenario3") + server.experimental.enable_tasks() + + elicit_received = Event() + work_completed = Event() + + @server.list_tools() + async def list_tools() -> list[Tool]: + return [ + Tool( + name="confirm_action", + description="Confirm an action", + inputSchema={"type": "object"}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + async def work(task: ServerTaskContext) -> CallToolResult: + # Normal elicitation within task - queued and delivered via tasks/result + result = await task.elicit( + message="Please confirm the action", + requestedSchema={"type": "object", "properties": {"confirm": {"type": "boolean"}}}, + ) + + confirmed = result.content.get("confirm", False) if result.content else False + work_completed.set() + return CallToolResult(content=[TextContent(type="text", text="confirmed" if confirmed else "cancelled")]) + + return await ctx.experimental.run_task(work) + + # Elicitation callback for client + async def elicitation_callback( + context: RequestContext[ClientSession, Any], + params: ElicitRequestParams, + ) -> ElicitResult: + elicit_received.set() + return ElicitResult(action="accept", content={"confirm": True}) + + # Set up streams + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def run_server() -> None: + await server.run( + client_to_server_receive, + server_to_client_send, + server.create_initialization_options( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ) + + async def run_client() -> None: + async with ClientSession( + server_to_client_receive, + client_to_server_send, + elicitation_callback=elicitation_callback, + ) as client_session: + await client_session.initialize() + + # Call tool as task + create_result = await client_session.experimental.call_tool_as_task("confirm_action", {}) + task_id = create_result.task.taskId + assert create_result.task.status == "working" + + # Poll until input_required, then call tasks/result + found_input_required = False + async for status in client_session.experimental.poll_task(task_id): # pragma: no branch + if status.status == "input_required": # pragma: no branch + found_input_required = True + break + assert found_input_required, "Expected to see input_required status" + + # This will deliver the elicitation and get the response + final_result = await client_session.experimental.get_task_result(task_id, CallToolResult) + + # Verify + assert elicit_received.is_set() + assert len(final_result.content) > 0 + assert isinstance(final_result.content[0], TextContent) + assert final_result.content[0].text == "confirmed" + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + tg.start_soon(run_client) + + assert work_completed.is_set() + + +@pytest.mark.anyio +async def test_scenario4_task_augmented_tool_task_augmented_elicitation() -> None: + """ + Scenario 4: Task-augmented tool call with task-augmented elicitation. + + Client calls tool as task. Inside the task, server uses task.elicit_as_task() + which sends task-augmented elicitation. Client creates its own task for the + elicitation, and server polls the client. + + This tests the full bidirectional flow where: + 1. Client calls tasks/result on server (for tool task) + 2. Server delivers task-augmented elicitation through that stream + 3. Client creates its own task and returns CreateTaskResult + 4. Server polls the client's task while the client's tasks/result is still open + 5. Server gets the ElicitResult and completes the tool task + 6. Client's tasks/result returns with the CallToolResult + """ + server = Server("test-scenario4") + server.experimental.enable_tasks() + + elicit_received = Event() + work_completed = Event() + + # Client-side task store for handling task-augmented elicitation + client_task_store = InMemoryTaskStore() + + @server.list_tools() + async def list_tools() -> list[Tool]: + return [ + Tool( + name="confirm_action", + description="Confirm an action", + inputSchema={"type": "object"}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + async def work(task: ServerTaskContext) -> CallToolResult: + # Task-augmented elicitation within task - server polls client + result = await task.elicit_as_task( + message="Please confirm the action", + requestedSchema={"type": "object", "properties": {"confirm": {"type": "boolean"}}}, + ttl=60000, + ) + + confirmed = result.content.get("confirm", False) if result.content else False + work_completed.set() + return CallToolResult(content=[TextContent(type="text", text="confirmed" if confirmed else "cancelled")]) + + return await ctx.experimental.run_task(work) + + task_handlers = create_client_task_handlers(client_task_store, elicit_received) + + # Set up streams + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def run_server() -> None: + await server.run( + client_to_server_receive, + server_to_client_send, + server.create_initialization_options( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ) + + async def run_client() -> None: + async with ClientSession( + server_to_client_receive, + client_to_server_send, + experimental_task_handlers=task_handlers, + ) as client_session: + await client_session.initialize() + + # Call tool as task + create_result = await client_session.experimental.call_tool_as_task("confirm_action", {}) + task_id = create_result.task.taskId + assert create_result.task.status == "working" + + # Poll until input_required or terminal, then call tasks/result + found_expected_status = False + async for status in client_session.experimental.poll_task(task_id): # pragma: no branch + if status.status == "input_required" or is_terminal(status.status): # pragma: no branch + found_expected_status = True + break + assert found_expected_status, "Expected to see input_required or terminal status" + + # This will deliver the task-augmented elicitation, + # server will poll client, and eventually return the tool result + final_result = await client_session.experimental.get_task_result(task_id, CallToolResult) + + # Verify + assert elicit_received.is_set() + assert len(final_result.content) > 0 + assert isinstance(final_result.content[0], TextContent) + assert final_result.content[0].text == "confirmed" + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + tg.start_soon(run_client) + + assert work_completed.is_set() + client_task_store.cleanup() + + +@pytest.mark.anyio +async def test_scenario2_sampling_normal_tool_task_augmented_sampling() -> None: + """ + Scenario 2 for sampling: Normal tool call with task-augmented sampling. + + Server calls session.experimental.create_message_as_task(), client creates + a task for the sampling and returns CreateTaskResult. Server polls client. + """ + server = Server("test-scenario2-sampling") + sampling_received = Event() + tool_result: list[str] = [] + + # Client-side task store for handling task-augmented sampling + client_task_store = InMemoryTaskStore() + + @server.list_tools() + async def list_tools() -> list[Tool]: + return [ + Tool( + name="generate_text", + description="Generate text using sampling", + inputSchema={"type": "object"}, + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult: + ctx = server.request_context + + # Task-augmented sampling - server polls client + result = await ctx.session.experimental.create_message_as_task( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))], + max_tokens=100, + ttl=60000, + ) + + assert isinstance(result.content, TextContent), "Expected TextContent response" + response_text = result.content.text + + tool_result.append(response_text) + return CallToolResult(content=[TextContent(type="text", text=response_text)]) + + task_handlers = create_sampling_task_handlers(client_task_store, sampling_received) + + # Set up streams + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def run_server() -> None: + await server.run( + client_to_server_receive, + server_to_client_send, + server.create_initialization_options( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ) + + async def run_client() -> None: + async with ClientSession( + server_to_client_receive, + client_to_server_send, + experimental_task_handlers=task_handlers, + ) as client_session: + await client_session.initialize() + + # Call tool normally (not as task) + result = await client_session.call_tool("generate_text", {}) + + # Verify sampling was received and tool completed + assert sampling_received.is_set() + assert len(result.content) > 0 + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello from the model!" + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + tg.start_soon(run_client) + + assert tool_result[0] == "Hello from the model!" + client_task_store.cleanup() + + +@pytest.mark.anyio +async def test_scenario4_sampling_task_augmented_tool_task_augmented_sampling() -> None: + """ + Scenario 4 for sampling: Task-augmented tool call with task-augmented sampling. + + Client calls tool as task. Inside the task, server uses task.create_message_as_task() + which sends task-augmented sampling. Client creates its own task for the sampling, + and server polls the client. + """ + server = Server("test-scenario4-sampling") + server.experimental.enable_tasks() + + sampling_received = Event() + work_completed = Event() + + # Client-side task store for handling task-augmented sampling + client_task_store = InMemoryTaskStore() + + @server.list_tools() + async def list_tools() -> list[Tool]: + return [ + Tool( + name="generate_text", + description="Generate text using sampling", + inputSchema={"type": "object"}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + async def work(task: ServerTaskContext) -> CallToolResult: + # Task-augmented sampling within task - server polls client + result = await task.create_message_as_task( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))], + max_tokens=100, + ttl=60000, + ) + + assert isinstance(result.content, TextContent), "Expected TextContent response" + response_text = result.content.text + + work_completed.set() + return CallToolResult(content=[TextContent(type="text", text=response_text)]) + + return await ctx.experimental.run_task(work) + + task_handlers = create_sampling_task_handlers(client_task_store, sampling_received) + + # Set up streams + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + async def run_server() -> None: + await server.run( + client_to_server_receive, + server_to_client_send, + server.create_initialization_options( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ) + + async def run_client() -> None: + async with ClientSession( + server_to_client_receive, + client_to_server_send, + experimental_task_handlers=task_handlers, + ) as client_session: + await client_session.initialize() + + # Call tool as task + create_result = await client_session.experimental.call_tool_as_task("generate_text", {}) + task_id = create_result.task.taskId + assert create_result.task.status == "working" + + # Poll until input_required or terminal + found_expected_status = False + async for status in client_session.experimental.poll_task(task_id): # pragma: no branch + if status.status == "input_required" or is_terminal(status.status): # pragma: no branch + found_expected_status = True + break + assert found_expected_status, "Expected to see input_required or terminal status" + + final_result = await client_session.experimental.get_task_result(task_id, CallToolResult) + + # Verify + assert sampling_received.is_set() + assert len(final_result.content) > 0 + assert isinstance(final_result.content[0], TextContent) + assert final_result.content[0].text == "Hello from the model!" + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + tg.start_soon(run_client) + + assert work_completed.is_set() + client_task_store.cleanup() diff --git a/tests/experimental/tasks/test_message_queue.py b/tests/experimental/tasks/test_message_queue.py new file mode 100644 index 0000000000..86d6875cc4 --- /dev/null +++ b/tests/experimental/tasks/test_message_queue.py @@ -0,0 +1,331 @@ +""" +Tests for TaskMessageQueue and InMemoryTaskMessageQueue. +""" + +from datetime import datetime, timezone + +import anyio +import pytest + +from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, QueuedMessage +from mcp.shared.experimental.tasks.resolver import Resolver +from mcp.types import JSONRPCNotification, JSONRPCRequest + + +@pytest.fixture +def queue() -> InMemoryTaskMessageQueue: + return InMemoryTaskMessageQueue() + + +def make_request(id: int = 1, method: str = "test/method") -> JSONRPCRequest: + return JSONRPCRequest(jsonrpc="2.0", id=id, method=method) + + +def make_notification(method: str = "test/notify") -> JSONRPCNotification: + return JSONRPCNotification(jsonrpc="2.0", method=method) + + +class TestInMemoryTaskMessageQueue: + @pytest.mark.anyio + async def test_enqueue_and_dequeue(self, queue: InMemoryTaskMessageQueue) -> None: + """Test basic enqueue and dequeue operations.""" + task_id = "task-1" + msg = QueuedMessage(type="request", message=make_request()) + + await queue.enqueue(task_id, msg) + result = await queue.dequeue(task_id) + + assert result is not None + assert result.type == "request" + assert result.message.method == "test/method" + + @pytest.mark.anyio + async def test_dequeue_empty_returns_none(self, queue: InMemoryTaskMessageQueue) -> None: + """Dequeue from empty queue returns None.""" + result = await queue.dequeue("nonexistent-task") + assert result is None + + @pytest.mark.anyio + async def test_fifo_ordering(self, queue: InMemoryTaskMessageQueue) -> None: + """Messages are dequeued in FIFO order.""" + task_id = "task-1" + + await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(1, "first"))) + await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(2, "second"))) + await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(3, "third"))) + + msg1 = await queue.dequeue(task_id) + msg2 = await queue.dequeue(task_id) + msg3 = await queue.dequeue(task_id) + + assert msg1 is not None and msg1.message.method == "first" + assert msg2 is not None and msg2.message.method == "second" + assert msg3 is not None and msg3.message.method == "third" + + @pytest.mark.anyio + async def test_separate_queues_per_task(self, queue: InMemoryTaskMessageQueue) -> None: + """Each task has its own queue.""" + await queue.enqueue("task-1", QueuedMessage(type="request", message=make_request(1, "task1-msg"))) + await queue.enqueue("task-2", QueuedMessage(type="request", message=make_request(2, "task2-msg"))) + + msg1 = await queue.dequeue("task-1") + msg2 = await queue.dequeue("task-2") + + assert msg1 is not None and msg1.message.method == "task1-msg" + assert msg2 is not None and msg2.message.method == "task2-msg" + + @pytest.mark.anyio + async def test_peek_does_not_remove(self, queue: InMemoryTaskMessageQueue) -> None: + """Peek returns message without removing it.""" + task_id = "task-1" + await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request())) + + peeked = await queue.peek(task_id) + dequeued = await queue.dequeue(task_id) + + assert peeked is not None + assert dequeued is not None + assert isinstance(peeked.message, JSONRPCRequest) + assert isinstance(dequeued.message, JSONRPCRequest) + assert peeked.message.id == dequeued.message.id + + @pytest.mark.anyio + async def test_is_empty(self, queue: InMemoryTaskMessageQueue) -> None: + """Test is_empty method.""" + task_id = "task-1" + + assert await queue.is_empty(task_id) is True + + await queue.enqueue(task_id, QueuedMessage(type="notification", message=make_notification())) + assert await queue.is_empty(task_id) is False + + await queue.dequeue(task_id) + assert await queue.is_empty(task_id) is True + + @pytest.mark.anyio + async def test_clear_returns_all_messages(self, queue: InMemoryTaskMessageQueue) -> None: + """Clear removes and returns all messages.""" + task_id = "task-1" + + await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(1))) + await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(2))) + await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request(3))) + + messages = await queue.clear(task_id) + + assert len(messages) == 3 + assert await queue.is_empty(task_id) is True + + @pytest.mark.anyio + async def test_clear_empty_queue(self, queue: InMemoryTaskMessageQueue) -> None: + """Clear on empty queue returns empty list.""" + messages = await queue.clear("nonexistent") + assert messages == [] + + @pytest.mark.anyio + async def test_notification_messages(self, queue: InMemoryTaskMessageQueue) -> None: + """Test queuing notification messages.""" + task_id = "task-1" + msg = QueuedMessage(type="notification", message=make_notification("log/message")) + + await queue.enqueue(task_id, msg) + result = await queue.dequeue(task_id) + + assert result is not None + assert result.type == "notification" + assert result.message.method == "log/message" + + @pytest.mark.anyio + async def test_message_timestamp(self, queue: InMemoryTaskMessageQueue) -> None: + """Messages have timestamps.""" + before = datetime.now(timezone.utc) + msg = QueuedMessage(type="request", message=make_request()) + after = datetime.now(timezone.utc) + + assert before <= msg.timestamp <= after + + @pytest.mark.anyio + async def test_message_with_resolver(self, queue: InMemoryTaskMessageQueue) -> None: + """Messages can have resolvers.""" + task_id = "task-1" + resolver: Resolver[dict[str, str]] = Resolver() + + msg = QueuedMessage( + type="request", + message=make_request(), + resolver=resolver, + original_request_id=42, + ) + + await queue.enqueue(task_id, msg) + result = await queue.dequeue(task_id) + + assert result is not None + assert result.resolver is resolver + assert result.original_request_id == 42 + + @pytest.mark.anyio + async def test_cleanup_specific_task(self, queue: InMemoryTaskMessageQueue) -> None: + """Cleanup removes specific task's data.""" + await queue.enqueue("task-1", QueuedMessage(type="request", message=make_request(1))) + await queue.enqueue("task-2", QueuedMessage(type="request", message=make_request(2))) + + queue.cleanup("task-1") + + assert await queue.is_empty("task-1") is True + assert await queue.is_empty("task-2") is False + + @pytest.mark.anyio + async def test_cleanup_all(self, queue: InMemoryTaskMessageQueue) -> None: + """Cleanup without task_id removes all data.""" + await queue.enqueue("task-1", QueuedMessage(type="request", message=make_request(1))) + await queue.enqueue("task-2", QueuedMessage(type="request", message=make_request(2))) + + queue.cleanup() + + assert await queue.is_empty("task-1") is True + assert await queue.is_empty("task-2") is True + + @pytest.mark.anyio + async def test_wait_for_message_returns_immediately_if_message_exists( + self, queue: InMemoryTaskMessageQueue + ) -> None: + """wait_for_message returns immediately if queue not empty.""" + task_id = "task-1" + await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request())) + + # Should return immediately, not block + with anyio.fail_after(1): + await queue.wait_for_message(task_id) + + @pytest.mark.anyio + async def test_wait_for_message_blocks_until_message(self, queue: InMemoryTaskMessageQueue) -> None: + """wait_for_message blocks until a message is enqueued.""" + task_id = "task-1" + received = False + waiter_started = anyio.Event() + + async def enqueue_when_ready() -> None: + # Wait until the waiter has started before enqueueing + await waiter_started.wait() + await queue.enqueue(task_id, QueuedMessage(type="request", message=make_request())) + + async def wait_for_msg() -> None: + nonlocal received + # Signal that we're about to start waiting + waiter_started.set() + await queue.wait_for_message(task_id) + received = True + + async with anyio.create_task_group() as tg: + tg.start_soon(wait_for_msg) + tg.start_soon(enqueue_when_ready) + + assert received is True + + @pytest.mark.anyio + async def test_notify_message_available_wakes_waiter(self, queue: InMemoryTaskMessageQueue) -> None: + """notify_message_available wakes up waiting coroutines.""" + task_id = "task-1" + notified = False + waiter_started = anyio.Event() + + async def notify_when_ready() -> None: + # Wait until the waiter has started before notifying + await waiter_started.wait() + await queue.notify_message_available(task_id) + + async def wait_for_notification() -> None: + nonlocal notified + # Signal that we're about to start waiting + waiter_started.set() + await queue.wait_for_message(task_id) + notified = True + + async with anyio.create_task_group() as tg: + tg.start_soon(wait_for_notification) + tg.start_soon(notify_when_ready) + + assert notified is True + + @pytest.mark.anyio + async def test_peek_empty_queue_returns_none(self, queue: InMemoryTaskMessageQueue) -> None: + """Peek on empty queue returns None.""" + result = await queue.peek("nonexistent-task") + assert result is None + + @pytest.mark.anyio + async def test_wait_for_message_double_check_race_condition(self, queue: InMemoryTaskMessageQueue) -> None: + """wait_for_message returns early if message arrives after event creation but before wait.""" + task_id = "task-1" + + # To test the double-check path (lines 223-225), we need a message to arrive + # after the event is created (line 220) but before event.wait() (line 228). + # We simulate this by injecting a message before is_empty is called the second time. + + original_is_empty = queue.is_empty + call_count = 0 + + async def is_empty_with_injection(tid: str) -> bool: + nonlocal call_count + call_count += 1 + if call_count == 2 and tid == task_id: + # Before second check, inject a message - this simulates a message + # arriving between event creation and the double-check + queue._queues[task_id] = [QueuedMessage(type="request", message=make_request())] + return await original_is_empty(tid) + + queue.is_empty = is_empty_with_injection # type: ignore[method-assign] + + # Should return immediately due to double-check finding the message + with anyio.fail_after(1): + await queue.wait_for_message(task_id) + + +class TestResolver: + @pytest.mark.anyio + async def test_set_result_and_wait(self) -> None: + """Test basic set_result and wait flow.""" + resolver: Resolver[str] = Resolver() + + resolver.set_result("hello") + result = await resolver.wait() + + assert result == "hello" + assert resolver.done() + + @pytest.mark.anyio + async def test_set_exception_and_wait(self) -> None: + """Test set_exception raises on wait.""" + resolver: Resolver[str] = Resolver() + + resolver.set_exception(ValueError("test error")) + + with pytest.raises(ValueError, match="test error"): + await resolver.wait() + + assert resolver.done() + + @pytest.mark.anyio + async def test_set_result_when_already_completed_raises(self) -> None: + """Test that set_result raises if resolver already completed.""" + resolver: Resolver[str] = Resolver() + resolver.set_result("first") + + with pytest.raises(RuntimeError, match="already completed"): + resolver.set_result("second") + + @pytest.mark.anyio + async def test_set_exception_when_already_completed_raises(self) -> None: + """Test that set_exception raises if resolver already completed.""" + resolver: Resolver[str] = Resolver() + resolver.set_result("done") + + with pytest.raises(RuntimeError, match="already completed"): + resolver.set_exception(ValueError("too late")) + + @pytest.mark.anyio + async def test_done_returns_false_before_completion(self) -> None: + """Test done() returns False before any result is set.""" + resolver: Resolver[str] = Resolver() + assert resolver.done() is False diff --git a/tests/experimental/tasks/test_request_context.py b/tests/experimental/tasks/test_request_context.py new file mode 100644 index 0000000000..5fa5da81af --- /dev/null +++ b/tests/experimental/tasks/test_request_context.py @@ -0,0 +1,166 @@ +"""Tests for the RequestContext.experimental (Experimental class) task validation helpers.""" + +import pytest + +from mcp.server.experimental.request_context import Experimental +from mcp.shared.exceptions import McpError +from mcp.types import ( + METHOD_NOT_FOUND, + TASK_FORBIDDEN, + TASK_OPTIONAL, + TASK_REQUIRED, + ClientCapabilities, + ClientTasksCapability, + TaskMetadata, + Tool, + ToolExecution, +) + + +def test_is_task_true_when_metadata_present() -> None: + exp = Experimental(task_metadata=TaskMetadata(ttl=60000)) + assert exp.is_task is True + + +def test_is_task_false_when_no_metadata() -> None: + exp = Experimental(task_metadata=None) + assert exp.is_task is False + + +def test_client_supports_tasks_true() -> None: + exp = Experimental(_client_capabilities=ClientCapabilities(tasks=ClientTasksCapability())) + assert exp.client_supports_tasks is True + + +def test_client_supports_tasks_false_no_tasks() -> None: + exp = Experimental(_client_capabilities=ClientCapabilities()) + assert exp.client_supports_tasks is False + + +def test_client_supports_tasks_false_no_capabilities() -> None: + exp = Experimental(_client_capabilities=None) + assert exp.client_supports_tasks is False + + +def test_validate_task_mode_required_with_task_is_valid() -> None: + exp = Experimental(task_metadata=TaskMetadata(ttl=60000)) + error = exp.validate_task_mode(TASK_REQUIRED, raise_error=False) + assert error is None + + +def test_validate_task_mode_required_without_task_returns_error() -> None: + exp = Experimental(task_metadata=None) + error = exp.validate_task_mode(TASK_REQUIRED, raise_error=False) + assert error is not None + assert error.code == METHOD_NOT_FOUND + assert "requires task-augmented" in error.message + + +def test_validate_task_mode_required_without_task_raises_by_default() -> None: + exp = Experimental(task_metadata=None) + with pytest.raises(McpError) as exc_info: + exp.validate_task_mode(TASK_REQUIRED) + assert exc_info.value.error.code == METHOD_NOT_FOUND + + +def test_validate_task_mode_forbidden_without_task_is_valid() -> None: + exp = Experimental(task_metadata=None) + error = exp.validate_task_mode(TASK_FORBIDDEN, raise_error=False) + assert error is None + + +def test_validate_task_mode_forbidden_with_task_returns_error() -> None: + exp = Experimental(task_metadata=TaskMetadata(ttl=60000)) + error = exp.validate_task_mode(TASK_FORBIDDEN, raise_error=False) + assert error is not None + assert error.code == METHOD_NOT_FOUND + assert "does not support task-augmented" in error.message + + +def test_validate_task_mode_forbidden_with_task_raises_by_default() -> None: + exp = Experimental(task_metadata=TaskMetadata(ttl=60000)) + with pytest.raises(McpError) as exc_info: + exp.validate_task_mode(TASK_FORBIDDEN) + assert exc_info.value.error.code == METHOD_NOT_FOUND + + +def test_validate_task_mode_none_treated_as_forbidden() -> None: + exp = Experimental(task_metadata=TaskMetadata(ttl=60000)) + error = exp.validate_task_mode(None, raise_error=False) + assert error is not None + assert "does not support task-augmented" in error.message + + +def test_validate_task_mode_optional_with_task_is_valid() -> None: + exp = Experimental(task_metadata=TaskMetadata(ttl=60000)) + error = exp.validate_task_mode(TASK_OPTIONAL, raise_error=False) + assert error is None + + +def test_validate_task_mode_optional_without_task_is_valid() -> None: + exp = Experimental(task_metadata=None) + error = exp.validate_task_mode(TASK_OPTIONAL, raise_error=False) + assert error is None + + +def test_validate_for_tool_with_execution_required() -> None: + exp = Experimental(task_metadata=None) + tool = Tool( + name="test", + description="test", + inputSchema={"type": "object"}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ) + error = exp.validate_for_tool(tool, raise_error=False) + assert error is not None + assert "requires task-augmented" in error.message + + +def test_validate_for_tool_without_execution() -> None: + exp = Experimental(task_metadata=TaskMetadata(ttl=60000)) + tool = Tool( + name="test", + description="test", + inputSchema={"type": "object"}, + execution=None, + ) + error = exp.validate_for_tool(tool, raise_error=False) + assert error is not None + assert "does not support task-augmented" in error.message + + +def test_validate_for_tool_optional_with_task() -> None: + exp = Experimental(task_metadata=TaskMetadata(ttl=60000)) + tool = Tool( + name="test", + description="test", + inputSchema={"type": "object"}, + execution=ToolExecution(taskSupport=TASK_OPTIONAL), + ) + error = exp.validate_for_tool(tool, raise_error=False) + assert error is None + + +def test_can_use_tool_required_with_task_support() -> None: + exp = Experimental(_client_capabilities=ClientCapabilities(tasks=ClientTasksCapability())) + assert exp.can_use_tool(TASK_REQUIRED) is True + + +def test_can_use_tool_required_without_task_support() -> None: + exp = Experimental(_client_capabilities=ClientCapabilities()) + assert exp.can_use_tool(TASK_REQUIRED) is False + + +def test_can_use_tool_optional_without_task_support() -> None: + exp = Experimental(_client_capabilities=ClientCapabilities()) + assert exp.can_use_tool(TASK_OPTIONAL) is True + + +def test_can_use_tool_forbidden_without_task_support() -> None: + exp = Experimental(_client_capabilities=ClientCapabilities()) + assert exp.can_use_tool(TASK_FORBIDDEN) is True + + +def test_can_use_tool_none_without_task_support() -> None: + exp = Experimental(_client_capabilities=ClientCapabilities()) + assert exp.can_use_tool(None) is True diff --git a/tests/experimental/tasks/test_spec_compliance.py b/tests/experimental/tasks/test_spec_compliance.py new file mode 100644 index 0000000000..842bfa7e1f --- /dev/null +++ b/tests/experimental/tasks/test_spec_compliance.py @@ -0,0 +1,753 @@ +""" +Tasks Spec Compliance Tests +=========================== + +Test structure mirrors: https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks.md + +Each section contains tests for normative requirements (MUST/SHOULD/MAY). +""" + +from datetime import datetime, timezone + +import pytest + +from mcp.server import Server +from mcp.server.lowlevel import NotificationOptions +from mcp.shared.experimental.tasks.helpers import MODEL_IMMEDIATE_RESPONSE_KEY +from mcp.types import ( + CancelTaskRequest, + CancelTaskResult, + CreateTaskResult, + GetTaskRequest, + GetTaskResult, + ListTasksRequest, + ListTasksResult, + ServerCapabilities, + Task, +) + +# Shared test datetime +TEST_DATETIME = datetime(2025, 1, 1, tzinfo=timezone.utc) + + +def _get_capabilities(server: Server) -> ServerCapabilities: + """Helper to get capabilities from a server.""" + return server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ) + + +def test_server_without_task_handlers_has_no_tasks_capability() -> None: + """Server without any task handlers has no tasks capability.""" + server: Server = Server("test") + caps = _get_capabilities(server) + assert caps.tasks is None + + +def test_server_with_list_tasks_handler_declares_list_capability() -> None: + """Server with list_tasks handler declares tasks.list capability.""" + server: Server = Server("test") + + @server.experimental.list_tasks() + async def handle_list(req: ListTasksRequest) -> ListTasksResult: + raise NotImplementedError + + caps = _get_capabilities(server) + assert caps.tasks is not None + assert caps.tasks.list is not None + + +def test_server_with_cancel_task_handler_declares_cancel_capability() -> None: + """Server with cancel_task handler declares tasks.cancel capability.""" + server: Server = Server("test") + + @server.experimental.cancel_task() + async def handle_cancel(req: CancelTaskRequest) -> CancelTaskResult: + raise NotImplementedError + + caps = _get_capabilities(server) + assert caps.tasks is not None + assert caps.tasks.cancel is not None + + +def test_server_with_get_task_handler_declares_requests_tools_call_capability() -> None: + """ + Server with get_task handler declares tasks.requests.tools.call capability. + (get_task is required for task-augmented tools/call support) + """ + server: Server = Server("test") + + @server.experimental.get_task() + async def handle_get(req: GetTaskRequest) -> GetTaskResult: + raise NotImplementedError + + caps = _get_capabilities(server) + assert caps.tasks is not None + assert caps.tasks.requests is not None + assert caps.tasks.requests.tools is not None + + +def test_server_without_list_handler_has_no_list_capability() -> None: + """Server without list_tasks handler has no tasks.list capability.""" + server: Server = Server("test") + + # Register only get_task (not list_tasks) + @server.experimental.get_task() + async def handle_get(req: GetTaskRequest) -> GetTaskResult: + raise NotImplementedError + + caps = _get_capabilities(server) + assert caps.tasks is not None + assert caps.tasks.list is None + + +def test_server_without_cancel_handler_has_no_cancel_capability() -> None: + """Server without cancel_task handler has no tasks.cancel capability.""" + server: Server = Server("test") + + # Register only get_task (not cancel_task) + @server.experimental.get_task() + async def handle_get(req: GetTaskRequest) -> GetTaskResult: + raise NotImplementedError + + caps = _get_capabilities(server) + assert caps.tasks is not None + assert caps.tasks.cancel is None + + +def test_server_with_all_task_handlers_has_full_capability() -> None: + """Server with all task handlers declares complete tasks capability.""" + server: Server = Server("test") + + @server.experimental.list_tasks() + async def handle_list(req: ListTasksRequest) -> ListTasksResult: + raise NotImplementedError + + @server.experimental.cancel_task() + async def handle_cancel(req: CancelTaskRequest) -> CancelTaskResult: + raise NotImplementedError + + @server.experimental.get_task() + async def handle_get(req: GetTaskRequest) -> GetTaskResult: + raise NotImplementedError + + caps = _get_capabilities(server) + assert caps.tasks is not None + assert caps.tasks.list is not None + assert caps.tasks.cancel is not None + assert caps.tasks.requests is not None + assert caps.tasks.requests.tools is not None + + +class TestClientCapabilities: + """ + Clients declare: + - tasks.list — supports listing operations + - tasks.cancel — supports cancellation + - tasks.requests.sampling.createMessage — task-augmented sampling + - tasks.requests.elicitation.create — task-augmented elicitation + """ + + def test_client_declares_tasks_capability(self) -> None: + """Client can declare tasks capability.""" + pytest.skip("TODO") + + +class TestToolLevelNegotiation: + """ + Tools in tools/list responses include execution.taskSupport with values: + - Not present or "forbidden": No task augmentation allowed + - "optional": Task augmentation allowed at requestor discretion + - "required": Task augmentation is mandatory + """ + + def test_tool_execution_task_forbidden_rejects_task_augmented_call(self) -> None: + """Tool with execution.taskSupport="forbidden" MUST reject task-augmented calls (-32601).""" + pytest.skip("TODO") + + def test_tool_execution_task_absent_rejects_task_augmented_call(self) -> None: + """Tool without execution.taskSupport MUST reject task-augmented calls (-32601).""" + pytest.skip("TODO") + + def test_tool_execution_task_optional_accepts_normal_call(self) -> None: + """Tool with execution.taskSupport="optional" accepts normal calls.""" + pytest.skip("TODO") + + def test_tool_execution_task_optional_accepts_task_augmented_call(self) -> None: + """Tool with execution.taskSupport="optional" accepts task-augmented calls.""" + pytest.skip("TODO") + + def test_tool_execution_task_required_rejects_normal_call(self) -> None: + """Tool with execution.taskSupport="required" MUST reject non-task calls (-32601).""" + pytest.skip("TODO") + + def test_tool_execution_task_required_accepts_task_augmented_call(self) -> None: + """Tool with execution.taskSupport="required" accepts task-augmented calls.""" + pytest.skip("TODO") + + +class TestCapabilityNegotiation: + """ + Requestors SHOULD only augment requests with a task if the corresponding + capability has been declared by the receiver. + + Receivers that do not declare the task capability for a request type + MUST process requests of that type normally, ignoring any task-augmentation + metadata if present. + """ + + def test_receiver_without_capability_ignores_task_metadata(self) -> None: + """ + Receiver without task capability MUST process request normally, + ignoring task-augmentation metadata. + """ + pytest.skip("TODO") + + def test_receiver_with_capability_may_require_task_augmentation(self) -> None: + """ + Receivers that declare task capability MAY return error (-32600) + for non-task-augmented requests, requiring task augmentation. + """ + pytest.skip("TODO") + + +class TestTaskStatusLifecycle: + """ + Tasks begin in working status and follow valid transitions: + working → input_required → working → terminal + working → terminal (directly) + input_required → terminal (directly) + + Terminal states (no further transitions allowed): + - completed + - failed + - cancelled + """ + + def test_task_begins_in_working_status(self) -> None: + """Tasks MUST begin in working status.""" + pytest.skip("TODO") + + def test_working_to_completed_transition(self) -> None: + """working → completed is valid.""" + pytest.skip("TODO") + + def test_working_to_failed_transition(self) -> None: + """working → failed is valid.""" + pytest.skip("TODO") + + def test_working_to_cancelled_transition(self) -> None: + """working → cancelled is valid.""" + pytest.skip("TODO") + + def test_working_to_input_required_transition(self) -> None: + """working → input_required is valid.""" + pytest.skip("TODO") + + def test_input_required_to_working_transition(self) -> None: + """input_required → working is valid.""" + pytest.skip("TODO") + + def test_input_required_to_terminal_transition(self) -> None: + """input_required → terminal is valid.""" + pytest.skip("TODO") + + def test_terminal_state_no_further_transitions(self) -> None: + """Terminal states allow no further transitions.""" + pytest.skip("TODO") + + def test_completed_is_terminal(self) -> None: + """completed is a terminal state.""" + pytest.skip("TODO") + + def test_failed_is_terminal(self) -> None: + """failed is a terminal state.""" + pytest.skip("TODO") + + def test_cancelled_is_terminal(self) -> None: + """cancelled is a terminal state.""" + pytest.skip("TODO") + + +class TestInputRequiredStatus: + """ + When a receiver needs information to proceed, it moves the task to input_required. + The requestor should call tasks/result to retrieve input requests. + The task must include io.modelcontextprotocol/related-task metadata in associated requests. + """ + + def test_input_required_status_retrievable_via_tasks_get(self) -> None: + """Task in input_required status is retrievable via tasks/get.""" + pytest.skip("TODO") + + def test_input_required_related_task_metadata_in_requests(self) -> None: + """ + Task MUST include io.modelcontextprotocol/related-task metadata + in associated requests. + """ + pytest.skip("TODO") + + +class TestCreatingTask: + """ + Request structure: + {"method": "tools/call", "params": {"name": "...", "arguments": {...}, "task": {"ttl": 60000}}} + + Response (CreateTaskResult): + {"result": {"task": {"taskId": "...", "status": "working", ...}}} + + Receivers may include io.modelcontextprotocol/model-immediate-response in _meta. + """ + + def test_task_augmented_request_returns_create_task_result(self) -> None: + """Task-augmented request MUST return CreateTaskResult immediately.""" + pytest.skip("TODO") + + def test_create_task_result_contains_task_id(self) -> None: + """CreateTaskResult MUST contain taskId.""" + pytest.skip("TODO") + + def test_create_task_result_contains_status_working(self) -> None: + """CreateTaskResult MUST have status=working initially.""" + pytest.skip("TODO") + + def test_create_task_result_contains_created_at(self) -> None: + """CreateTaskResult MUST contain createdAt timestamp.""" + pytest.skip("TODO") + + def test_create_task_result_created_at_is_iso8601(self) -> None: + """createdAt MUST be ISO 8601 formatted.""" + pytest.skip("TODO") + + def test_create_task_result_may_contain_ttl(self) -> None: + """CreateTaskResult MAY contain ttl.""" + pytest.skip("TODO") + + def test_create_task_result_may_contain_poll_interval(self) -> None: + """CreateTaskResult MAY contain pollInterval.""" + pytest.skip("TODO") + + def test_create_task_result_may_contain_status_message(self) -> None: + """CreateTaskResult MAY contain statusMessage.""" + pytest.skip("TODO") + + def test_receiver_may_override_requested_ttl(self) -> None: + """Receiver MAY override requested ttl but MUST return actual value.""" + pytest.skip("TODO") + + def test_model_immediate_response_in_meta(self) -> None: + """ + Receiver MAY include io.modelcontextprotocol/model-immediate-response + in _meta to provide immediate response while task executes. + """ + # Verify the constant has the correct value per spec + assert MODEL_IMMEDIATE_RESPONSE_KEY == "io.modelcontextprotocol/model-immediate-response" + + # CreateTaskResult can include model-immediate-response in _meta + task = Task( + taskId="test-123", + status="working", + createdAt=TEST_DATETIME, + lastUpdatedAt=TEST_DATETIME, + ttl=60000, + ) + immediate_msg = "Task started, processing your request..." + # Note: Must use _meta= (alias) not meta= due to Pydantic alias handling + result = CreateTaskResult( + task=task, + **{"_meta": {MODEL_IMMEDIATE_RESPONSE_KEY: immediate_msg}}, + ) + + # Verify the metadata is present and correct + assert result.meta is not None + assert MODEL_IMMEDIATE_RESPONSE_KEY in result.meta + assert result.meta[MODEL_IMMEDIATE_RESPONSE_KEY] == immediate_msg + + # Verify it serializes correctly with _meta alias + serialized = result.model_dump(by_alias=True) + assert "_meta" in serialized + assert MODEL_IMMEDIATE_RESPONSE_KEY in serialized["_meta"] + assert serialized["_meta"][MODEL_IMMEDIATE_RESPONSE_KEY] == immediate_msg + + +class TestGettingTaskStatus: + """ + Request: {"method": "tasks/get", "params": {"taskId": "..."}} + Response: Returns full Task object with current status and pollInterval. + """ + + def test_tasks_get_returns_task_object(self) -> None: + """tasks/get MUST return full Task object.""" + pytest.skip("TODO") + + def test_tasks_get_returns_current_status(self) -> None: + """tasks/get MUST return current status.""" + pytest.skip("TODO") + + def test_tasks_get_may_return_poll_interval(self) -> None: + """tasks/get MAY return pollInterval.""" + pytest.skip("TODO") + + def test_tasks_get_invalid_task_id_returns_error(self) -> None: + """tasks/get with invalid taskId MUST return -32602.""" + pytest.skip("TODO") + + def test_tasks_get_nonexistent_task_id_returns_error(self) -> None: + """tasks/get with nonexistent taskId MUST return -32602.""" + pytest.skip("TODO") + + +class TestRetrievingResults: + """ + Request: {"method": "tasks/result", "params": {"taskId": "..."}} + Response: The actual operation result structure (e.g., CallToolResult). + + This call blocks until terminal status. + """ + + def test_tasks_result_returns_underlying_result(self) -> None: + """tasks/result MUST return exactly what underlying request would return.""" + pytest.skip("TODO") + + def test_tasks_result_blocks_until_terminal(self) -> None: + """tasks/result MUST block for non-terminal tasks.""" + pytest.skip("TODO") + + def test_tasks_result_unblocks_on_terminal(self) -> None: + """tasks/result MUST unblock upon reaching terminal status.""" + pytest.skip("TODO") + + def test_tasks_result_includes_related_task_metadata(self) -> None: + """tasks/result MUST include io.modelcontextprotocol/related-task in _meta.""" + pytest.skip("TODO") + + def test_tasks_result_returns_error_for_failed_task(self) -> None: + """ + tasks/result returns the same error the underlying request + would have produced for failed tasks. + """ + pytest.skip("TODO") + + def test_tasks_result_invalid_task_id_returns_error(self) -> None: + """tasks/result with invalid taskId MUST return -32602.""" + pytest.skip("TODO") + + +class TestListingTasks: + """ + Request: {"method": "tasks/list", "params": {"cursor": "optional"}} + Response: Array of tasks with pagination support via nextCursor. + """ + + def test_tasks_list_returns_array_of_tasks(self) -> None: + """tasks/list MUST return array of tasks.""" + pytest.skip("TODO") + + def test_tasks_list_pagination_with_cursor(self) -> None: + """tasks/list supports pagination via cursor.""" + pytest.skip("TODO") + + def test_tasks_list_returns_next_cursor_when_more_results(self) -> None: + """tasks/list MUST return nextCursor when more results available.""" + pytest.skip("TODO") + + def test_tasks_list_cursors_are_opaque(self) -> None: + """Implementers MUST treat cursors as opaque tokens.""" + pytest.skip("TODO") + + def test_tasks_list_invalid_cursor_returns_error(self) -> None: + """tasks/list with invalid cursor MUST return -32602.""" + pytest.skip("TODO") + + +class TestCancellingTasks: + """ + Request: {"method": "tasks/cancel", "params": {"taskId": "..."}} + Response: Returns the task object with status: "cancelled". + """ + + def test_tasks_cancel_returns_cancelled_task(self) -> None: + """tasks/cancel MUST return task with status=cancelled.""" + pytest.skip("TODO") + + def test_tasks_cancel_terminal_task_returns_error(self) -> None: + """Cancelling already-terminal task MUST return -32602.""" + pytest.skip("TODO") + + def test_tasks_cancel_completed_task_returns_error(self) -> None: + """Cancelling completed task MUST return -32602.""" + pytest.skip("TODO") + + def test_tasks_cancel_failed_task_returns_error(self) -> None: + """Cancelling failed task MUST return -32602.""" + pytest.skip("TODO") + + def test_tasks_cancel_already_cancelled_task_returns_error(self) -> None: + """Cancelling already-cancelled task MUST return -32602.""" + pytest.skip("TODO") + + def test_tasks_cancel_invalid_task_id_returns_error(self) -> None: + """tasks/cancel with invalid taskId MUST return -32602.""" + pytest.skip("TODO") + + +class TestStatusNotifications: + """ + Receivers MAY send: {"method": "notifications/tasks/status", "params": {...}} + These are optional; requestors MUST NOT rely on them and SHOULD continue polling. + """ + + def test_receiver_may_send_status_notification(self) -> None: + """Receiver MAY send notifications/tasks/status.""" + pytest.skip("TODO") + + def test_status_notification_contains_task_id(self) -> None: + """Status notification MUST contain taskId.""" + pytest.skip("TODO") + + def test_status_notification_contains_status(self) -> None: + """Status notification MUST contain status.""" + pytest.skip("TODO") + + +class TestTaskManagement: + """ + - Receivers generate unique task IDs as strings + - Tasks must begin in working status + - createdAt timestamps must be ISO 8601 formatted + - Receivers may override requested ttl but must return actual value + - Receivers may delete tasks after TTL expires + - All task-related messages must include io.modelcontextprotocol/related-task + in _meta except for tasks/get, tasks/list, tasks/cancel operations + """ + + def test_task_ids_are_unique_strings(self) -> None: + """Receivers MUST generate unique task IDs as strings.""" + pytest.skip("TODO") + + def test_multiple_tasks_have_unique_ids(self) -> None: + """Multiple tasks MUST have unique IDs.""" + pytest.skip("TODO") + + def test_receiver_may_delete_tasks_after_ttl(self) -> None: + """Receivers MAY delete tasks after TTL expires.""" + pytest.skip("TODO") + + def test_related_task_metadata_in_task_messages(self) -> None: + """ + All task-related messages MUST include io.modelcontextprotocol/related-task + in _meta. + """ + pytest.skip("TODO") + + def test_tasks_get_does_not_require_related_task_metadata(self) -> None: + """tasks/get does not require related-task metadata.""" + pytest.skip("TODO") + + def test_tasks_list_does_not_require_related_task_metadata(self) -> None: + """tasks/list does not require related-task metadata.""" + pytest.skip("TODO") + + def test_tasks_cancel_does_not_require_related_task_metadata(self) -> None: + """tasks/cancel does not require related-task metadata.""" + pytest.skip("TODO") + + +class TestResultHandling: + """ + - Receivers must return CreateTaskResult immediately upon accepting task-augmented requests + - tasks/result must return exactly what the underlying request would return + - tasks/result blocks for non-terminal tasks; must unblock upon reaching terminal status + """ + + def test_create_task_result_returned_immediately(self) -> None: + """Receiver MUST return CreateTaskResult immediately (not after work completes).""" + pytest.skip("TODO") + + def test_tasks_result_matches_underlying_result_structure(self) -> None: + """tasks/result MUST return same structure as underlying request.""" + pytest.skip("TODO") + + def test_tasks_result_for_tool_call_returns_call_tool_result(self) -> None: + """tasks/result for tools/call returns CallToolResult.""" + pytest.skip("TODO") + + +class TestProgressTracking: + """ + Task-augmented requests support progress notifications using the progressToken + mechanism, which remains valid throughout the task lifetime. + """ + + def test_progress_token_valid_throughout_task_lifetime(self) -> None: + """progressToken remains valid throughout task lifetime.""" + pytest.skip("TODO") + + def test_progress_notifications_sent_during_task_execution(self) -> None: + """Progress notifications can be sent during task execution.""" + pytest.skip("TODO") + + +class TestProtocolErrors: + """ + Protocol Errors (JSON-RPC standard codes): + - -32600 (Invalid request): Non-task requests to endpoint requiring task augmentation + - -32602 (Invalid params): Invalid/nonexistent taskId, invalid cursor, cancel terminal task + - -32603 (Internal error): Server-side execution failures + """ + + def test_invalid_request_for_required_task_augmentation(self) -> None: + """Non-task request to task-required endpoint returns -32600.""" + pytest.skip("TODO") + + def test_invalid_params_for_invalid_task_id(self) -> None: + """Invalid taskId returns -32602.""" + pytest.skip("TODO") + + def test_invalid_params_for_nonexistent_task_id(self) -> None: + """Nonexistent taskId returns -32602.""" + pytest.skip("TODO") + + def test_invalid_params_for_invalid_cursor(self) -> None: + """Invalid cursor in tasks/list returns -32602.""" + pytest.skip("TODO") + + def test_invalid_params_for_cancel_terminal_task(self) -> None: + """Attempt to cancel terminal task returns -32602.""" + pytest.skip("TODO") + + def test_internal_error_for_server_failure(self) -> None: + """Server-side execution failure returns -32603.""" + pytest.skip("TODO") + + +class TestTaskExecutionErrors: + """ + When underlying requests fail, the task moves to failed status. + - tasks/get response should include statusMessage explaining failure + - tasks/result returns same error the underlying request would have produced + - For tool calls, isError: true moves task to failed status + """ + + def test_underlying_failure_moves_task_to_failed(self) -> None: + """Underlying request failure moves task to failed status.""" + pytest.skip("TODO") + + def test_failed_task_has_status_message(self) -> None: + """Failed task SHOULD include statusMessage explaining failure.""" + pytest.skip("TODO") + + def test_tasks_result_returns_underlying_error(self) -> None: + """tasks/result returns same error underlying request would produce.""" + pytest.skip("TODO") + + def test_tool_call_is_error_true_moves_to_failed(self) -> None: + """Tool call with isError: true moves task to failed status.""" + pytest.skip("TODO") + + +class TestTaskObject: + """ + Task Object fields: + - taskId: String identifier + - status: Current execution state + - statusMessage: Optional human-readable description + - createdAt: ISO 8601 timestamp of creation + - ttl: Milliseconds before potential deletion + - pollInterval: Suggested milliseconds between polls + """ + + def test_task_has_task_id_string(self) -> None: + """Task MUST have taskId as string.""" + pytest.skip("TODO") + + def test_task_has_status(self) -> None: + """Task MUST have status.""" + pytest.skip("TODO") + + def test_task_status_message_is_optional(self) -> None: + """Task statusMessage is optional.""" + pytest.skip("TODO") + + def test_task_has_created_at(self) -> None: + """Task MUST have createdAt.""" + pytest.skip("TODO") + + def test_task_ttl_is_optional(self) -> None: + """Task ttl is optional.""" + pytest.skip("TODO") + + def test_task_poll_interval_is_optional(self) -> None: + """Task pollInterval is optional.""" + pytest.skip("TODO") + + +class TestRelatedTaskMetadata: + """ + Related Task Metadata structure: + {"_meta": {"io.modelcontextprotocol/related-task": {"taskId": "..."}}} + """ + + def test_related_task_metadata_structure(self) -> None: + """Related task metadata has correct structure.""" + pytest.skip("TODO") + + def test_related_task_metadata_contains_task_id(self) -> None: + """Related task metadata contains taskId.""" + pytest.skip("TODO") + + +class TestAccessAndIsolation: + """ + - Task IDs enable access to sensitive results + - Authorization context binding is essential where available + - For non-authorized environments: strong entropy IDs, strict TTL limits + """ + + def test_task_bound_to_authorization_context(self) -> None: + """ + Receivers receiving authorization context MUST bind tasks to that context. + """ + pytest.skip("TODO") + + def test_reject_task_operations_outside_authorization_context(self) -> None: + """ + Receivers MUST reject task operations for tasks outside + requestor's authorization context. + """ + pytest.skip("TODO") + + def test_non_authorized_environments_use_secure_ids(self) -> None: + """ + For non-authorized environments, receivers SHOULD use + cryptographically secure IDs. + """ + pytest.skip("TODO") + + def test_non_authorized_environments_use_shorter_ttls(self) -> None: + """ + For non-authorized environments, receivers SHOULD use shorter TTLs. + """ + pytest.skip("TODO") + + +class TestResourceLimits: + """ + Receivers should: + - Enforce concurrent task limits per requestor + - Implement maximum TTL constraints + - Clean up expired tasks promptly + """ + + def test_concurrent_task_limit_enforced(self) -> None: + """Receiver SHOULD enforce concurrent task limits per requestor.""" + pytest.skip("TODO") + + def test_maximum_ttl_constraint_enforced(self) -> None: + """Receiver SHOULD implement maximum TTL constraints.""" + pytest.skip("TODO") + + def test_expired_tasks_cleaned_up(self) -> None: + """Receiver SHOULD clean up expired tasks promptly.""" + pytest.skip("TODO") diff --git a/tests/issues/test_100_tool_listing.py b/tests/issues/test_100_tool_listing.py index 2bc386c967..9e3447b741 100644 --- a/tests/issues/test_100_tool_listing.py +++ b/tests/issues/test_100_tool_listing.py @@ -13,13 +13,11 @@ async def test_list_tools_returns_all_tools(): for i in range(num_tools): @mcp.tool(name=f"tool_{i}") - def dummy_tool_func(): + def dummy_tool_func(): # pragma: no cover f"""Tool number {i}""" return i - globals()[f"dummy_tool_{i}"] = ( - dummy_tool_func # Keep reference to avoid garbage collection - ) + globals()[f"dummy_tool_{i}"] = dummy_tool_func # Keep reference to avoid garbage collection # Get all tools tools = await mcp.list_tools() @@ -30,6 +28,4 @@ def dummy_tool_func(): # Verify each tool is unique and has the correct name tool_names = [tool.name for tool in tools] expected_names = [f"tool_{i}" for i in range(num_tools)] - assert sorted(tool_names) == sorted( - expected_names - ), "Tool names don't match expected names" + assert sorted(tool_names) == sorted(expected_names), "Tool names don't match expected names" diff --git a/tests/issues/test_1027_win_unreachable_cleanup.py b/tests/issues/test_1027_win_unreachable_cleanup.py new file mode 100644 index 0000000000..63d6dd8dcf --- /dev/null +++ b/tests/issues/test_1027_win_unreachable_cleanup.py @@ -0,0 +1,249 @@ +""" +Regression test for issue #1027: Ensure cleanup procedures run properly during shutdown + +Issue #1027 reported that cleanup code after "yield" in lifespan was unreachable when +processes were terminated. This has been fixed by implementing the MCP spec-compliant +stdio shutdown sequence that closes stdin first, allowing graceful exit. + +These tests verify the fix continues to work correctly across all platforms. +""" + +import sys +import tempfile +import textwrap +from pathlib import Path +from typing import TYPE_CHECKING + +import anyio +import pytest + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import _create_platform_compatible_process, stdio_client + +# TODO(Marcelo): This doesn't seem to be the right path. We should fix this. +if TYPE_CHECKING: + from ..shared.test_win32_utils import escape_path_for_python +else: + from tests.shared.test_win32_utils import escape_path_for_python + + +@pytest.mark.anyio +async def test_lifespan_cleanup_executed(): + """ + Regression test ensuring MCP server cleanup code runs during shutdown. + + This test verifies that the fix for issue #1027 works correctly by: + 1. Starting an MCP server that writes a marker file on startup + 2. Shutting down the server normally via stdio_client + 3. Verifying the cleanup code (after yield) executed and wrote its marker file + + The fix implements proper stdin closure before termination, giving servers + time to run their cleanup handlers. + """ + + # Create marker files to track server lifecycle + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f: + startup_marker = f.name + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f: + cleanup_marker = f.name + + # Remove the files so we can detect when they're created + Path(startup_marker).unlink() + Path(cleanup_marker).unlink() + + # Create a minimal MCP server using FastMCP that tracks lifecycle + server_code = textwrap.dedent(f""" + import asyncio + import sys + from pathlib import Path + from contextlib import asynccontextmanager + from mcp.server.fastmcp import FastMCP + + STARTUP_MARKER = {escape_path_for_python(startup_marker)} + CLEANUP_MARKER = {escape_path_for_python(cleanup_marker)} + + @asynccontextmanager + async def lifespan(server): + # Write startup marker + Path(STARTUP_MARKER).write_text("started") + try: + yield {{"started": True}} + finally: + # This cleanup code now runs properly during shutdown + Path(CLEANUP_MARKER).write_text("cleaned up") + + mcp = FastMCP("test-server", lifespan=lifespan) + + @mcp.tool() + def echo(text: str) -> str: + return text + + if __name__ == "__main__": + mcp.run() + """) + + # Write the server script to a temporary file + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".py") as f: + server_script = f.name + f.write(server_code) + + try: + # Launch the MCP server + params = StdioServerParameters(command=sys.executable, args=[server_script]) + + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the session + result = await session.initialize() + assert result.protocolVersion in ["2024-11-05", "2025-06-18", "2025-11-25"] + + # Verify startup marker was created + assert Path(startup_marker).exists(), "Server startup marker not created" + assert Path(startup_marker).read_text() == "started" + + # Make a test request to ensure server is working + response = await session.call_tool("echo", {"text": "hello"}) + assert response.content[0].type == "text" + assert getattr(response.content[0], "text") == "hello" + + # Session will be closed when exiting the context manager + + # Give server a moment to complete cleanup + with anyio.move_on_after(5.0): + while not Path(cleanup_marker).exists(): # pragma: no cover + await anyio.sleep(0.1) + + # Verify cleanup marker was created - this works now that stdio_client + # properly closes stdin before termination, allowing graceful shutdown + assert Path(cleanup_marker).exists(), "Server cleanup marker not created - regression in issue #1027 fix" + assert Path(cleanup_marker).read_text() == "cleaned up" + + finally: + # Clean up files + for path in [server_script, startup_marker, cleanup_marker]: + try: # pragma: no cover + Path(path).unlink() + except FileNotFoundError: # pragma: no cover + pass + + +@pytest.mark.anyio +@pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default") +async def test_stdin_close_triggers_cleanup(): + """ + Regression test verifying the stdin-based graceful shutdown mechanism. + + This test ensures the core fix for issue #1027 continues to work by: + 1. Manually managing a server process + 2. Closing stdin to trigger graceful shutdown + 3. Verifying cleanup handlers run before the process exits + + This mimics the behavior now implemented in stdio_client's shutdown sequence. + + Note on Windows ResourceWarning: + On Windows, we may see ResourceWarning about unclosed file descriptors. + This is expected behavior because: + - We're manually managing the process lifecycle + - Windows file handle cleanup works differently than Unix + - The warning doesn't indicate a real issue - cleanup still works + We filter this warning on Windows only to avoid test noise. + """ + + # Create marker files to track server lifecycle + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f: + startup_marker = f.name + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f: + cleanup_marker = f.name + + # Remove the files so we can detect when they're created + Path(startup_marker).unlink() + Path(cleanup_marker).unlink() + + # Create an MCP server that handles stdin closure gracefully + server_code = textwrap.dedent(f""" + import asyncio + import sys + from pathlib import Path + from contextlib import asynccontextmanager + from mcp.server.fastmcp import FastMCP + + STARTUP_MARKER = {escape_path_for_python(startup_marker)} + CLEANUP_MARKER = {escape_path_for_python(cleanup_marker)} + + @asynccontextmanager + async def lifespan(server): + # Write startup marker + Path(STARTUP_MARKER).write_text("started") + try: + yield {{"started": True}} + finally: + # This cleanup code runs when stdin closes, enabling graceful shutdown + Path(CLEANUP_MARKER).write_text("cleaned up") + + mcp = FastMCP("test-server", lifespan=lifespan) + + @mcp.tool() + def echo(text: str) -> str: + return text + + if __name__ == "__main__": + # The server should exit gracefully when stdin closes + try: + mcp.run() + except Exception: + # Server might get EOF or other errors when stdin closes + pass + """) + + # Write the server script to a temporary file + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".py") as f: + server_script = f.name + f.write(server_code) + + try: + # This test manually manages the process to verify stdin-based shutdown + # Start the server process + process = await _create_platform_compatible_process( + command=sys.executable, args=[server_script], env=None, errlog=sys.stderr, cwd=None + ) + + # Wait for server to start + with anyio.move_on_after(10.0): + while not Path(startup_marker).exists(): + await anyio.sleep(0.1) + + # Check if process is still running + if hasattr(process, "returncode") and process.returncode is not None: # pragma: no cover + pytest.fail(f"Server process exited with code {process.returncode}") + + assert Path(startup_marker).exists(), "Server startup marker not created" + + # Close stdin to signal shutdown + if process.stdin: # pragma: no branch + await process.stdin.aclose() + + # Wait for process to exit gracefully + try: + with anyio.fail_after(5.0): # Increased from 2.0 to 5.0 + await process.wait() + except TimeoutError: # pragma: no cover + # If it doesn't exit after stdin close, terminate it + process.terminate() + await process.wait() + + # Check if cleanup ran + with anyio.move_on_after(5.0): + while not Path(cleanup_marker).exists(): # pragma: no cover + await anyio.sleep(0.1) + + # Verify the cleanup ran - stdin closure enables graceful shutdown + assert Path(cleanup_marker).exists(), "Server cleanup marker not created - stdin-based shutdown failed" + assert Path(cleanup_marker).read_text() == "cleaned up" + + finally: + # Clean up files + for path in [server_script, startup_marker, cleanup_marker]: + try: # pragma: no cover + Path(path).unlink() + except FileNotFoundError: # pragma: no cover + pass diff --git a/tests/issues/test_129_resource_templates.py b/tests/issues/test_129_resource_templates.py index e6eff3d463..958773d127 100644 --- a/tests/issues/test_129_resource_templates.py +++ b/tests/issues/test_129_resource_templates.py @@ -11,12 +11,12 @@ async def test_resource_templates(): # Add a dynamic greeting resource @mcp.resource("greeting://{name}") - def get_greeting(name: str) -> str: + def get_greeting(name: str) -> str: # pragma: no cover """Get a personalized greeting""" return f"Hello, {name}!" @mcp.resource("users://{user_id}/profile") - def get_user_profile(user_id: str) -> str: + def get_user_profile(user_id: str) -> str: # pragma: no cover """Dynamic user data""" return f"Profile data for user {user_id}" @@ -24,9 +24,7 @@ def get_user_profile(user_id: str) -> str: # Note: list_resource_templates() returns a decorator that wraps the handler # The handler returns a ServerResult with a ListResourceTemplatesResult inside result = await mcp._mcp_server.request_handlers[types.ListResourceTemplatesRequest]( - types.ListResourceTemplatesRequest( - method="resources/templates/list", params=None, cursor=None - ) + types.ListResourceTemplatesRequest(params=None) ) assert isinstance(result.root, types.ListResourceTemplatesResult) templates = result.root.resourceTemplates @@ -35,10 +33,10 @@ def get_user_profile(user_id: str) -> str: assert len(templates) == 2 # Verify template details - greeting_template = next(t for t in templates if t.name == "get_greeting") + greeting_template = next(t for t in templates if t.name == "get_greeting") # pragma: no cover assert greeting_template.uriTemplate == "greeting://{name}" assert greeting_template.description == "Get a personalized greeting" - profile_template = next(t for t in templates if t.name == "get_user_profile") + profile_template = next(t for t in templates if t.name == "get_user_profile") # pragma: no cover assert profile_template.uriTemplate == "users://{user_id}/profile" assert profile_template.description == "Dynamic user data" diff --git a/tests/issues/test_1338_icons_and_metadata.py b/tests/issues/test_1338_icons_and_metadata.py new file mode 100644 index 0000000000..adc37f1c6e --- /dev/null +++ b/tests/issues/test_1338_icons_and_metadata.py @@ -0,0 +1,142 @@ +"""Test icon and metadata support (SEP-973).""" + +import pytest + +from mcp.server.fastmcp import FastMCP +from mcp.types import Icon + +pytestmark = pytest.mark.anyio + + +async def test_icons_and_website_url(): + """Test that icons and websiteUrl are properly returned in API calls.""" + + # Create test icon + test_icon = Icon( + src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + mimeType="image/png", + sizes=["1x1"], + ) + + # Create server with website URL and icon + mcp = FastMCP("TestServer", website_url="https://example.com", icons=[test_icon]) + + # Create tool with icon + @mcp.tool(icons=[test_icon]) + def test_tool(message: str) -> str: # pragma: no cover + """A test tool with an icon.""" + return message + + # Create resource with icon + @mcp.resource("test://resource", icons=[test_icon]) + def test_resource() -> str: # pragma: no cover + """A test resource with an icon.""" + return "test content" + + # Create prompt with icon + @mcp.prompt("test_prompt", icons=[test_icon]) + def test_prompt(text: str) -> str: # pragma: no cover + """A test prompt with an icon.""" + return text + + # Create resource template with icon + @mcp.resource("test://weather/{city}", icons=[test_icon]) + def test_resource_template(city: str) -> str: # pragma: no cover + """Get weather for a city.""" + return f"Weather for {city}" + + # Test server metadata includes websiteUrl and icons + assert mcp.name == "TestServer" + assert mcp.website_url == "https://example.com" + assert mcp.icons is not None + assert len(mcp.icons) == 1 + assert mcp.icons[0].src == test_icon.src + assert mcp.icons[0].mimeType == test_icon.mimeType + assert mcp.icons[0].sizes == test_icon.sizes + + # Test tool includes icon + tools = await mcp.list_tools() + assert len(tools) == 1 + tool = tools[0] + assert tool.name == "test_tool" + assert tool.icons is not None + assert len(tool.icons) == 1 + assert tool.icons[0].src == test_icon.src + + # Test resource includes icon + resources = await mcp.list_resources() + assert len(resources) == 1 + resource = resources[0] + assert str(resource.uri) == "test://resource" + assert resource.icons is not None + assert len(resource.icons) == 1 + assert resource.icons[0].src == test_icon.src + + # Test prompt includes icon + prompts = await mcp.list_prompts() + assert len(prompts) == 1 + prompt = prompts[0] + assert prompt.name == "test_prompt" + assert prompt.icons is not None + assert len(prompt.icons) == 1 + assert prompt.icons[0].src == test_icon.src + + # Test resource template includes icon + templates = await mcp.list_resource_templates() + assert len(templates) == 1 + template = templates[0] + assert template.name == "test_resource_template" + assert template.uriTemplate == "test://weather/{city}" + assert template.icons is not None + assert len(template.icons) == 1 + assert template.icons[0].src == test_icon.src + + +async def test_multiple_icons(): + """Test that multiple icons can be added to tools, resources, and prompts.""" + + # Create multiple test icons + icon1 = Icon(src="data:image/png;base64,icon1", mimeType="image/png", sizes=["16x16"]) + icon2 = Icon(src="data:image/png;base64,icon2", mimeType="image/png", sizes=["32x32"]) + icon3 = Icon(src="data:image/png;base64,icon3", mimeType="image/png", sizes=["64x64"]) + + mcp = FastMCP("MultiIconServer") + + # Create tool with multiple icons + @mcp.tool(icons=[icon1, icon2, icon3]) + def multi_icon_tool() -> str: # pragma: no cover + """A tool with multiple icons.""" + return "success" + + # Test tool has all icons + tools = await mcp.list_tools() + assert len(tools) == 1 + tool = tools[0] + assert tool.icons is not None + assert len(tool.icons) == 3 + assert tool.icons[0].sizes == ["16x16"] + assert tool.icons[1].sizes == ["32x32"] + assert tool.icons[2].sizes == ["64x64"] + + +async def test_no_icons_or_website(): + """Test that server works without icons or websiteUrl.""" + + mcp = FastMCP("BasicServer") + + @mcp.tool() + def basic_tool() -> str: # pragma: no cover + """A basic tool without icons.""" + return "success" + + # Test server metadata has no websiteUrl or icons + assert mcp.name == "BasicServer" + assert mcp.website_url is None + assert mcp.icons is None + + # Test tool has no icons + tools = await mcp.list_tools() + assert len(tools) == 1 + tool = tools[0] + assert tool.name == "basic_tool" + assert tool.icons is None diff --git a/tests/issues/test_1363_race_condition_streamable_http.py b/tests/issues/test_1363_race_condition_streamable_http.py new file mode 100644 index 0000000000..49242d6d8b --- /dev/null +++ b/tests/issues/test_1363_race_condition_streamable_http.py @@ -0,0 +1,278 @@ +"""Test for issue #1363 - Race condition in StreamableHTTP transport causes ClosedResourceError. + +This test reproduces the race condition described in issue #1363 where MCP servers +in HTTP Streamable mode experience ClosedResourceError exceptions when requests +fail validation early (e.g., due to incorrect Accept headers). + +The race condition occurs because: +1. Transport setup creates a message_router task +2. Message router enters async for write_stream_reader loop +3. write_stream_reader calls checkpoint() in receive(), yielding control +4. Request handling processes HTTP request +5. If validation fails early, request returns immediately +6. Transport termination closes all streams including write_stream_reader +7. Message router may still be in checkpoint() yield and hasn't returned to check stream state +8. When message router resumes, it encounters a closed stream, raising ClosedResourceError +""" + +import logging +import threading +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +import anyio +import httpx +import pytest +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager + +SERVER_NAME = "test_race_condition_server" + + +class RaceConditionTestServer(Server): + def __init__(self): + super().__init__(SERVER_NAME) + + +def create_app(json_response: bool = False) -> Starlette: + """Create a Starlette application for testing.""" + app = RaceConditionTestServer() + + # Create session manager + session_manager = StreamableHTTPSessionManager( + app=app, + json_response=json_response, + stateless=True, # Use stateless mode to trigger the race condition + ) + + # Create Starlette app with lifespan + @asynccontextmanager + async def lifespan(app: Starlette) -> AsyncGenerator[None, None]: + async with session_manager.run(): + yield + + routes = [ + Mount("/", app=session_manager.handle_request), + ] + + return Starlette(routes=routes, lifespan=lifespan) + + +class ServerThread(threading.Thread): + """Thread that runs the ASGI application lifespan in a separate event loop.""" + + def __init__(self, app: Starlette): + super().__init__(daemon=True) + self.app = app + self._stop_event = threading.Event() + + def run(self) -> None: + """Run the lifespan in a new event loop.""" + + # Create a new event loop for this thread + async def run_lifespan(): + # Use the lifespan context (always present in our tests) + lifespan_context = getattr(self.app.router, "lifespan_context", None) + assert lifespan_context is not None # Tests always create apps with lifespan + async with lifespan_context(self.app): + # Wait until stop is requested + while not self._stop_event.is_set(): + await anyio.sleep(0.1) + + anyio.run(run_lifespan) + + def stop(self) -> None: + """Signal the thread to stop.""" + self._stop_event.set() + + +def check_logs_for_race_condition_errors(caplog: pytest.LogCaptureFixture, test_name: str) -> None: + """ + Check logs for ClosedResourceError and other race condition errors. + + Args: + caplog: pytest log capture fixture + test_name: Name of the test for better error messages + """ + # Check for specific race condition errors in logs + errors_found: list[str] = [] + + for record in caplog.records: # pragma: no cover + message = record.getMessage() + if "ClosedResourceError" in message: + errors_found.append("ClosedResourceError") + if "Error in message router" in message: + errors_found.append("Error in message router") + if "anyio.ClosedResourceError" in message: + errors_found.append("anyio.ClosedResourceError") + + # Assert no race condition errors occurred + if errors_found: # pragma: no cover + error_msg = f"Test '{test_name}' found race condition errors in logs: {', '.join(set(errors_found))}\n" + error_msg += "Log records:\n" + for record in caplog.records: + if any(err in record.getMessage() for err in ["ClosedResourceError", "Error in message router"]): + error_msg += f" {record.levelname}: {record.getMessage()}\n" + pytest.fail(error_msg) + + +@pytest.mark.anyio +async def test_race_condition_invalid_accept_headers(caplog: pytest.LogCaptureFixture): + """ + Test the race condition with invalid Accept headers. + + This test reproduces the exact scenario described in issue #1363: + - Send POST request with incorrect Accept headers (missing either application/json or text/event-stream) + - Request fails validation early and returns quickly + - This should trigger the race condition where message_router encounters ClosedResourceError + """ + app = create_app() + server_thread = ServerThread(app) + server_thread.start() + + try: + # Give the server thread a moment to start + await anyio.sleep(0.1) + + # Suppress WARNING logs (expected validation errors) and capture ERROR logs + with caplog.at_level(logging.ERROR): + # Test with missing text/event-stream in Accept header + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 + ) as client: + response = await client.post( + "/", + json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, + headers={ + "Accept": "application/json", # Missing text/event-stream + "Content-Type": "application/json", + }, + ) + # Should get 406 Not Acceptable due to missing text/event-stream + assert response.status_code == 406 + + # Test with missing application/json in Accept header + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 + ) as client: + response = await client.post( + "/", + json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, + headers={ + "Accept": "text/event-stream", # Missing application/json + "Content-Type": "application/json", + }, + ) + # Should get 406 Not Acceptable due to missing application/json + assert response.status_code == 406 + + # Test with completely invalid Accept header + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 + ) as client: + response = await client.post( + "/", + json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, + headers={ + "Accept": "text/plain", # Invalid Accept header + "Content-Type": "application/json", + }, + ) + # Should get 406 Not Acceptable + assert response.status_code == 406 + + # Give background tasks time to complete + await anyio.sleep(0.2) + + finally: + server_thread.stop() + server_thread.join(timeout=5.0) + # Check logs for race condition errors + check_logs_for_race_condition_errors(caplog, "test_race_condition_invalid_accept_headers") + + +@pytest.mark.anyio +async def test_race_condition_invalid_content_type(caplog: pytest.LogCaptureFixture): + """ + Test the race condition with invalid Content-Type headers. + + This test reproduces the race condition scenario with Content-Type validation failure. + """ + app = create_app() + server_thread = ServerThread(app) + server_thread.start() + + try: + # Give the server thread a moment to start + await anyio.sleep(0.1) + + # Suppress WARNING logs (expected validation errors) and capture ERROR logs + with caplog.at_level(logging.ERROR): + # Test with invalid Content-Type + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 + ) as client: + response = await client.post( + "/", + json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "text/plain", # Invalid Content-Type + }, + ) + assert response.status_code == 400 + + # Give background tasks time to complete + await anyio.sleep(0.2) + + finally: + server_thread.stop() + server_thread.join(timeout=5.0) + # Check logs for race condition errors + check_logs_for_race_condition_errors(caplog, "test_race_condition_invalid_content_type") + + +@pytest.mark.anyio +async def test_race_condition_message_router_async_for(caplog: pytest.LogCaptureFixture): + """ + Uses json_response=True to trigger the `if self.is_json_response_enabled` branch, + which reproduces the ClosedResourceError when message_router is suspended + in async for loop while transport cleanup closes streams concurrently. + """ + app = create_app(json_response=True) + server_thread = ServerThread(app) + server_thread.start() + + try: + # Give the server thread a moment to start + await anyio.sleep(0.1) + + # Suppress WARNING logs (expected validation errors) and capture ERROR logs + with caplog.at_level(logging.ERROR): + # Use httpx.ASGITransport to test the ASGI app directly + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 + ) as client: + # Send a valid initialize request + response = await client.post( + "/", + json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + ) + # Should get a successful response + assert response.status_code in (200, 201) + + # Give background tasks time to complete + await anyio.sleep(0.2) + + finally: + server_thread.stop() + server_thread.join(timeout=5.0) + # Check logs for race condition errors in message router + check_logs_for_race_condition_errors(caplog, "test_race_condition_message_router_async_for") diff --git a/tests/issues/test_141_resource_templates.py b/tests/issues/test_141_resource_templates.py index 3c17cd5592..0a0484d894 100644 --- a/tests/issues/test_141_resource_templates.py +++ b/tests/issues/test_141_resource_templates.py @@ -25,28 +25,28 @@ def get_user_post(user_id: str, post_id: str) -> str: with pytest.raises(ValueError, match="Mismatch between URI parameters"): @mcp.resource("resource://users/{user_id}/profile") - def get_user_profile(user_id: str, optional_param: str | None = None) -> str: + def get_user_profile(user_id: str, optional_param: str | None = None) -> str: # pragma: no cover return f"Profile for user {user_id}" # Test case 3: Template with mismatched parameters with pytest.raises(ValueError, match="Mismatch between URI parameters"): @mcp.resource("resource://users/{user_id}/profile") - def get_user_profile_mismatch(different_param: str) -> str: + def get_user_profile_mismatch(different_param: str) -> str: # pragma: no cover return f"Profile for user {different_param}" # Test case 4: Template with extra function parameters with pytest.raises(ValueError, match="Mismatch between URI parameters"): @mcp.resource("resource://users/{user_id}/profile") - def get_user_profile_extra(user_id: str, extra_param: str) -> str: + def get_user_profile_extra(user_id: str, extra_param: str) -> str: # pragma: no cover return f"Profile for user {user_id}" # Test case 5: Template with missing function parameters with pytest.raises(ValueError, match="Mismatch between URI parameters"): @mcp.resource("resource://users/{user_id}/profile/{section}") - def get_user_profile_missing(user_id: str) -> str: + def get_user_profile_missing(user_id: str) -> str: # pragma: no cover return f"Profile for user {user_id}" # Verify valid template works @@ -61,9 +61,7 @@ def get_user_profile_missing(user_id: str) -> str: await mcp.read_resource("resource://users/123/posts") # Missing post_id with pytest.raises(ValueError, match="Unknown resource"): - await mcp.read_resource( - "resource://users/123/posts/456/extra" - ) # Extra path component + await mcp.read_resource("resource://users/123/posts/456/extra") # Extra path component @pytest.mark.anyio @@ -110,11 +108,7 @@ def get_user_profile(user_id: str) -> str: # Verify invalid resource URIs raise appropriate errors with pytest.raises(Exception): # Specific exception type may vary - await session.read_resource( - AnyUrl("resource://users/123/posts") - ) # Missing post_id + await session.read_resource(AnyUrl("resource://users/123/posts")) # Missing post_id with pytest.raises(Exception): # Specific exception type may vary - await session.read_resource( - AnyUrl("resource://users/123/invalid") - ) # Invalid template + await session.read_resource(AnyUrl("resource://users/123/invalid")) # Invalid template diff --git a/tests/issues/test_152_resource_mime_type.py b/tests/issues/test_152_resource_mime_type.py index 1143195e59..2a8cd6202e 100644 --- a/tests/issues/test_152_resource_mime_type.py +++ b/tests/issues/test_152_resource_mime_type.py @@ -45,31 +45,19 @@ def get_image_as_bytes() -> bytes: bytes_resource = mapping["test://image_bytes"] # Verify mime types - assert ( - string_resource.mimeType == "image/png" - ), "String resource mime type not respected" - assert ( - bytes_resource.mimeType == "image/png" - ), "Bytes resource mime type not respected" + assert string_resource.mimeType == "image/png", "String resource mime type not respected" + assert bytes_resource.mimeType == "image/png", "Bytes resource mime type not respected" # Also verify the content can be read correctly string_result = await client.read_resource(AnyUrl("test://image")) assert len(string_result.contents) == 1 - assert ( - getattr(string_result.contents[0], "text") == base64_string - ), "Base64 string mismatch" - assert ( - string_result.contents[0].mimeType == "image/png" - ), "String content mime type not preserved" + assert getattr(string_result.contents[0], "text") == base64_string, "Base64 string mismatch" + assert string_result.contents[0].mimeType == "image/png", "String content mime type not preserved" bytes_result = await client.read_resource(AnyUrl("test://image_bytes")) assert len(bytes_result.contents) == 1 - assert ( - base64.b64decode(getattr(bytes_result.contents[0], "blob")) == image_bytes - ), "Bytes mismatch" - assert ( - bytes_result.contents[0].mimeType == "image/png" - ), "Bytes content mime type not preserved" + assert base64.b64decode(getattr(bytes_result.contents[0], "blob")) == image_bytes, "Bytes mismatch" + assert bytes_result.contents[0].mimeType == "image/png", "Bytes content mime type not preserved" async def test_lowlevel_resource_mime_type(): @@ -82,9 +70,7 @@ async def test_lowlevel_resource_mime_type(): # Create test resources with specific mime types test_resources = [ - types.Resource( - uri=AnyUrl("test://image"), name="test image", mimeType="image/png" - ), + types.Resource(uri=AnyUrl("test://image"), name="test image", mimeType="image/png"), types.Resource( uri=AnyUrl("test://image_bytes"), name="test image bytes", @@ -101,10 +87,8 @@ async def handle_read_resource(uri: AnyUrl): if str(uri) == "test://image": return [ReadResourceContents(content=base64_string, mime_type="image/png")] elif str(uri) == "test://image_bytes": - return [ - ReadResourceContents(content=bytes(image_bytes), mime_type="image/png") - ] - raise Exception(f"Resource not found: {uri}") + return [ReadResourceContents(content=bytes(image_bytes), mime_type="image/png")] + raise Exception(f"Resource not found: {uri}") # pragma: no cover # Test that resources are listed with correct mime type async with client_session(server) as client: @@ -119,28 +103,16 @@ async def handle_read_resource(uri: AnyUrl): bytes_resource = mapping["test://image_bytes"] # Verify mime types - assert ( - string_resource.mimeType == "image/png" - ), "String resource mime type not respected" - assert ( - bytes_resource.mimeType == "image/png" - ), "Bytes resource mime type not respected" + assert string_resource.mimeType == "image/png", "String resource mime type not respected" + assert bytes_resource.mimeType == "image/png", "Bytes resource mime type not respected" # Also verify the content can be read correctly string_result = await client.read_resource(AnyUrl("test://image")) assert len(string_result.contents) == 1 - assert ( - getattr(string_result.contents[0], "text") == base64_string - ), "Base64 string mismatch" - assert ( - string_result.contents[0].mimeType == "image/png" - ), "String content mime type not preserved" + assert getattr(string_result.contents[0], "text") == base64_string, "Base64 string mismatch" + assert string_result.contents[0].mimeType == "image/png", "String content mime type not preserved" bytes_result = await client.read_resource(AnyUrl("test://image_bytes")) assert len(bytes_result.contents) == 1 - assert ( - base64.b64decode(getattr(bytes_result.contents[0], "blob")) == image_bytes - ), "Bytes mismatch" - assert ( - bytes_result.contents[0].mimeType == "image/png" - ), "Bytes content mime type not preserved" + assert base64.b64decode(getattr(bytes_result.contents[0], "blob")) == image_bytes, "Bytes mismatch" + assert bytes_result.contents[0].mimeType == "image/png", "Bytes content mime type not preserved" diff --git a/tests/issues/test_1754_mime_type_parameters.py b/tests/issues/test_1754_mime_type_parameters.py new file mode 100644 index 0000000000..cd8239ad2a --- /dev/null +++ b/tests/issues/test_1754_mime_type_parameters.py @@ -0,0 +1,70 @@ +"""Test for GitHub issue #1754: MIME type validation rejects valid RFC 2045 parameters. + +The MIME type validation regex was too restrictive and rejected valid MIME types +with parameters like 'text/html;profile=mcp-app' which are valid per RFC 2045. +""" + +import pytest +from pydantic import AnyUrl + +from mcp.server.fastmcp import FastMCP +from mcp.shared.memory import ( + create_connected_server_and_client_session as client_session, +) + +pytestmark = pytest.mark.anyio + + +async def test_mime_type_with_parameters(): + """Test that MIME types with parameters are accepted (RFC 2045).""" + mcp = FastMCP("test") + + # This should NOT raise a validation error + @mcp.resource("ui://widget", mime_type="text/html;profile=mcp-app") + def widget() -> str: + raise NotImplementedError() + + resources = await mcp.list_resources() + assert len(resources) == 1 + assert resources[0].mimeType == "text/html;profile=mcp-app" + + +async def test_mime_type_with_parameters_and_space(): + """Test MIME type with space after semicolon.""" + mcp = FastMCP("test") + + @mcp.resource("data://json", mime_type="application/json; charset=utf-8") + def data() -> str: + raise NotImplementedError() + + resources = await mcp.list_resources() + assert len(resources) == 1 + assert resources[0].mimeType == "application/json; charset=utf-8" + + +async def test_mime_type_with_multiple_parameters(): + """Test MIME type with multiple parameters.""" + mcp = FastMCP("test") + + @mcp.resource("data://multi", mime_type="text/plain; charset=utf-8; format=fixed") + def data() -> str: + raise NotImplementedError() + + resources = await mcp.list_resources() + assert len(resources) == 1 + assert resources[0].mimeType == "text/plain; charset=utf-8; format=fixed" + + +async def test_mime_type_preserved_in_read_resource(): + """Test that MIME type with parameters is preserved when reading resource.""" + mcp = FastMCP("test") + + @mcp.resource("ui://my-widget", mime_type="text/html;profile=mcp-app") + def my_widget() -> str: + return "Hello MCP-UI" + + async with client_session(mcp._mcp_server) as client: + # Read the resource + result = await client.read_resource(AnyUrl("ui://my-widget")) + assert len(result.contents) == 1 + assert result.contents[0].mimeType == "text/html;profile=mcp-app" diff --git a/tests/issues/test_176_progress_token.py b/tests/issues/test_176_progress_token.py index 7f9131a1e0..eb5f19d64c 100644 --- a/tests/issues/test_176_progress_token.py +++ b/tests/issues/test_176_progress_token.py @@ -35,15 +35,7 @@ async def test_progress_token_zero_first_call(): await ctx.report_progress(10, 10) # Complete # Verify progress notifications - assert ( - mock_session.send_progress_notification.call_count == 3 - ), "All progress notifications should be sent" - mock_session.send_progress_notification.assert_any_call( - progress_token=0, progress=0.0, total=10.0 - ) - mock_session.send_progress_notification.assert_any_call( - progress_token=0, progress=5.0, total=10.0 - ) - mock_session.send_progress_notification.assert_any_call( - progress_token=0, progress=10.0, total=10.0 - ) + assert mock_session.send_progress_notification.call_count == 3, "All progress notifications should be sent" + mock_session.send_progress_notification.assert_any_call(progress_token=0, progress=0.0, total=10.0, message=None) + mock_session.send_progress_notification.assert_any_call(progress_token=0, progress=5.0, total=10.0, message=None) + mock_session.send_progress_notification.assert_any_call(progress_token=0, progress=10.0, total=10.0, message=None) diff --git a/tests/issues/test_188_concurrency.py b/tests/issues/test_188_concurrency.py index 2aa6c49cb3..831736510b 100644 --- a/tests/issues/test_188_concurrency.py +++ b/tests/issues/test_188_concurrency.py @@ -3,49 +3,84 @@ from pydantic import AnyUrl from mcp.server.fastmcp import FastMCP -from mcp.shared.memory import ( - create_connected_server_and_client_session as create_session, -) - -_sleep_time_seconds = 0.01 -_resource_name = "slow://slow_resource" +from mcp.shared.memory import create_connected_server_and_client_session as create_session @pytest.mark.anyio -async def test_messages_are_executed_concurrently(): +async def test_messages_are_executed_concurrently_tools(): server = FastMCP("test") + event = anyio.Event() + tool_started = anyio.Event() + call_order: list[str] = [] @server.tool("sleep") async def sleep_tool(): - await anyio.sleep(_sleep_time_seconds) + call_order.append("waiting_for_event") + tool_started.set() + await event.wait() + call_order.append("tool_end") return "done" - @server.resource(_resource_name) - async def slow_resource(): - await anyio.sleep(_sleep_time_seconds) + @server.tool("trigger") + async def trigger(): + # Wait for tool to start before setting the event + await tool_started.wait() + call_order.append("trigger_started") + event.set() + call_order.append("trigger_end") return "slow" async with create_session(server._mcp_server) as client_session: - start_time = anyio.current_time() + # First tool will wait on event, second will set it async with anyio.create_task_group() as tg: - for _ in range(10): - tg.start_soon(client_session.call_tool, "sleep") - tg.start_soon(client_session.read_resource, AnyUrl(_resource_name)) - - end_time = anyio.current_time() + # Start the tool first (it will wait on event) + tg.start_soon(client_session.call_tool, "sleep") + # Then the trigger tool will set the event to allow the first tool to continue + await client_session.call_tool("trigger") - duration = end_time - start_time - assert duration < 3 * _sleep_time_seconds - print(duration) + # Verify that both ran concurrently + assert call_order == [ + "waiting_for_event", + "trigger_started", + "trigger_end", + "tool_end", + ], f"Expected concurrent execution, but got: {call_order}" -def main(): - anyio.run(test_messages_are_executed_concurrently) +@pytest.mark.anyio +async def test_messages_are_executed_concurrently_tools_and_resources(): + server = FastMCP("test") + event = anyio.Event() + tool_started = anyio.Event() + call_order: list[str] = [] + @server.tool("sleep") + async def sleep_tool(): + call_order.append("waiting_for_event") + tool_started.set() + await event.wait() + call_order.append("tool_end") + return "done" -if __name__ == "__main__": - import logging + @server.resource("slow://slow_resource") + async def slow_resource(): + # Wait for tool to start before setting the event + await tool_started.wait() + event.set() + call_order.append("resource_end") + return "slow" - logging.basicConfig(level=logging.DEBUG) + async with create_session(server._mcp_server) as client_session: + # First tool will wait on event, second will set it + async with anyio.create_task_group() as tg: + # Start the tool first (it will wait on event) + tg.start_soon(client_session.call_tool, "sleep") + # Then the resource (it will set the event) + tg.start_soon(client_session.read_resource, AnyUrl("slow://slow_resource")) - main() + # Verify that both ran concurrently + assert call_order == [ + "waiting_for_event", + "resource_end", + "tool_end", + ], f"Expected concurrent execution, but got: {call_order}" diff --git a/tests/issues/test_192_request_id.py b/tests/issues/test_192_request_id.py index 00e1878958..3762b092bd 100644 --- a/tests/issues/test_192_request_id.py +++ b/tests/issues/test_192_request_id.py @@ -3,6 +3,7 @@ from mcp.server.lowlevel import NotificationOptions, Server from mcp.server.models import InitializationOptions +from mcp.shared.message import SessionMessage from mcp.types import ( LATEST_PROTOCOL_VERSION, ClientCapabilities, @@ -11,6 +12,7 @@ JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, + JSONRPCResponse, NotificationParams, ) @@ -22,8 +24,8 @@ async def test_request_id_match() -> None: custom_request_id = "test-123" # Create memory streams for communication - client_writer, client_reader = anyio.create_memory_object_stream(1) - server_writer, server_reader = anyio.create_memory_object_stream(1) + client_writer, client_reader = anyio.create_memory_object_stream[SessionMessage | Exception](1) + server_writer, server_reader = anyio.create_memory_object_stream[SessionMessage | Exception](1) # Server task to process the request async def run_server(): @@ -64,8 +66,8 @@ async def run_server(): jsonrpc="2.0", ) - await client_writer.send(JSONRPCMessage(root=init_req)) - await server_reader.receive() # Get init response but don't need to check it + await client_writer.send(SessionMessage(JSONRPCMessage(root=init_req))) + response = await server_reader.receive() # Get init response but don't need to check it # Send initialized notification initialized_notification = JSONRPCNotification( @@ -73,22 +75,21 @@ async def run_server(): params=NotificationParams().model_dump(by_alias=True, exclude_none=True), jsonrpc="2.0", ) - await client_writer.send(JSONRPCMessage(root=initialized_notification)) + await client_writer.send(SessionMessage(JSONRPCMessage(root=initialized_notification))) # Send ping request with custom ID - ping_request = JSONRPCRequest( - id=custom_request_id, method="ping", params={}, jsonrpc="2.0" - ) + ping_request = JSONRPCRequest(id=custom_request_id, method="ping", params={}, jsonrpc="2.0") - await client_writer.send(JSONRPCMessage(root=ping_request)) + await client_writer.send(SessionMessage(JSONRPCMessage(root=ping_request))) # Read response response = await server_reader.receive() # Verify response ID matches request ID - assert ( - response.root.id == custom_request_id - ), "Response ID should match request ID" + assert isinstance(response, SessionMessage) + assert isinstance(response.message, JSONRPCMessage) + assert isinstance(response.message.root, JSONRPCResponse) + assert response.message.root.id == custom_request_id, "Response ID should match request ID" # Cancel server task tg.cancel_scope.cancel() diff --git a/tests/issues/test_342_base64_encoding.py b/tests/issues/test_342_base64_encoding.py index cff8ec543f..da56959975 100644 --- a/tests/issues/test_342_base64_encoding.py +++ b/tests/issues/test_342_base64_encoding.py @@ -47,18 +47,13 @@ async def test_server_base64_encoding_issue(): # Register a resource handler that returns our test data @server.read_resource() async def read_resource(uri: AnyUrl) -> list[ReadResourceContents]: - return [ - ReadResourceContents( - content=binary_data, mime_type="application/octet-stream" - ) - ] + return [ReadResourceContents(content=binary_data, mime_type="application/octet-stream")] # Get the handler directly from the server handler = server.request_handlers[ReadResourceRequest] # Create a request request = ReadResourceRequest( - method="resources/read", params=ReadResourceRequestParams(uri=AnyUrl("test://resource")), ) diff --git a/tests/issues/test_355_type_error.py b/tests/issues/test_355_type_error.py index 91416e5caf..63ed803846 100644 --- a/tests/issues/test_355_type_error.py +++ b/tests/issues/test_355_type_error.py @@ -3,17 +3,18 @@ from dataclasses import dataclass from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession class Database: # Replace with your actual DB type @classmethod - async def connect(cls): + async def connect(cls): # pragma: no cover return cls() - async def disconnect(self): + async def disconnect(self): # pragma: no cover pass - def query(self): + def query(self): # pragma: no cover return "Hello, World!" @@ -27,7 +28,7 @@ class AppContext: @asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: +async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma: no cover """Manage application lifecycle with type-safe context""" # Initialize on startup db = await Database.connect() @@ -44,7 +45,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # Access type-safe lifespan context in tools @mcp.tool() -def query_db(ctx: Context) -> str: +def query_db(ctx: Context[ServerSession, AppContext]) -> str: # pragma: no cover """Tool that uses initialized resources""" db = ctx.request_context.lifespan_context.db return db.query() diff --git a/tests/issues/test_552_windows_hang.py b/tests/issues/test_552_windows_hang.py new file mode 100644 index 0000000000..972659c2b7 --- /dev/null +++ b/tests/issues/test_552_windows_hang.py @@ -0,0 +1,64 @@ +"""Test for issue #552: stdio_client hangs on Windows.""" + +import sys +from textwrap import dedent + +import anyio +import pytest + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test") # pragma: no cover +@pytest.mark.anyio +async def test_windows_stdio_client_with_session(): + """ + Test the exact scenario from issue #552: Using ClientSession with stdio_client. + + This reproduces the original bug report where stdio_client hangs on Windows 11 + when used with ClientSession. + """ + # Create a minimal MCP server that responds to initialization + server_script = dedent(""" + import json + import sys + + # Read initialization request + line = sys.stdin.readline() + + # Send initialization response + response = { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "1.0", + "capabilities": {}, + "serverInfo": {"name": "test-server", "version": "1.0"} + } + } + print(json.dumps(response)) + sys.stdout.flush() + + # Exit after a short delay + import time + time.sleep(0.1) + sys.exit(0) + """).strip() + + params = StdioServerParameters( + command=sys.executable, + args=["-c", server_script], + ) + + # This is the exact pattern from the bug report + with anyio.fail_after(10): + try: + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + # Should exit ClientSession without hanging + # Should exit stdio_client without hanging + except Exception: + # Connection errors are expected when process exits + pass diff --git a/tests/issues/test_88_random_error.py b/tests/issues/test_88_random_error.py index 88e41d66da..ac370ca160 100644 --- a/tests/issues/test_88_random_error.py +++ b/tests/issues/test_88_random_error.py @@ -1,21 +1,20 @@ """Test to reproduce issue #88: Random error thrown on response.""" from collections.abc import Sequence -from datetime import timedelta from pathlib import Path +from typing import Any import anyio import pytest from anyio.abc import TaskStatus +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from mcp import types from mcp.client.session import ClientSession from mcp.server.lowlevel import Server from mcp.shared.exceptions import McpError -from mcp.types import ( - EmbeddedResource, - ImageContent, - TextContent, -) +from mcp.shared.message import SessionMessage +from mcp.types import ContentBlock, TextContent @pytest.mark.anyio @@ -27,37 +26,46 @@ async def test_notification_validation_error(tmp_path: Path): 2. The server can still handle new requests 3. The client can make new requests 4. No resources are leaked + + Uses per-request timeouts to avoid race conditions: + - Fast operations use no timeout (reliable in any environment) + - Slow operations use minimal timeout (10ms) for quick test execution """ server = Server(name="test") request_count = 0 - slow_request_started = anyio.Event() - slow_request_complete = anyio.Event() + slow_request_lock = anyio.Event() + + @server.list_tools() + async def list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="slow", + description="A slow tool", + inputSchema={"type": "object"}, + ), + types.Tool( + name="fast", + description="A fast tool", + inputSchema={"type": "object"}, + ), + ] @server.call_tool() - async def slow_tool( - name: str, arg - ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: + async def slow_tool(name: str, arguments: dict[str, Any]) -> Sequence[ContentBlock]: nonlocal request_count request_count += 1 if name == "slow": - # Signal that slow request has started - slow_request_started.set() - # Long enough to ensure timeout - await anyio.sleep(0.2) - # Signal completion - slow_request_complete.set() + await slow_request_lock.wait() # it should timeout here return [TextContent(type="text", text=f"slow {request_count}")] elif name == "fast": - # Fast enough to complete before timeout - await anyio.sleep(0.01) return [TextContent(type="text", text=f"fast {request_count}")] - return [TextContent(type="text", text=f"unknown {request_count}")] + return [TextContent(type="text", text=f"unknown {request_count}")] # pragma: no cover async def server_handler( - read_stream, - write_stream, + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], + write_stream: MemoryObjectSendStream[SessionMessage], task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED, ): with anyio.CancelScope() as scope: @@ -69,39 +77,38 @@ async def server_handler( raise_exceptions=True, ) - async def client(read_stream, write_stream, scope): - # Use a timeout that's: - # - Long enough for fast operations (>10ms) - # - Short enough for slow operations (<200ms) - # - Not too short to avoid flakiness - async with ClientSession( - read_stream, write_stream, read_timeout_seconds=timedelta(milliseconds=50) - ) as session: + async def client( + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], + write_stream: MemoryObjectSendStream[SessionMessage], + scope: anyio.CancelScope, + ): + # No session-level timeout to avoid race conditions with fast operations + async with ClientSession(read_stream, write_stream) as session: await session.initialize() - # First call should work (fast operation) - result = await session.call_tool("fast") + # First call should work (fast operation, no timeout) + result = await session.call_tool("fast", read_timeout_seconds=None) assert result.content == [TextContent(type="text", text="fast 1")] - assert not slow_request_complete.is_set() + assert not slow_request_lock.is_set() - # Second call should timeout (slow operation) + # Second call should timeout (slow operation with minimal timeout) + # Use very small timeout to trigger quickly without waiting with pytest.raises(McpError) as exc_info: - await session.call_tool("slow") + await session.call_tool("slow", read_timeout_seconds=0.000001) # artificial timeout that always fails assert "Timed out while waiting" in str(exc_info.value) - # Wait for slow request to complete in the background - with anyio.fail_after(1): # Timeout after 1 second - await slow_request_complete.wait() + # release the slow request not to have hanging process + slow_request_lock.set() - # Third call should work (fast operation), + # Third call should work (fast operation, no timeout), # proving server is still responsive - result = await session.call_tool("fast") + result = await session.call_tool("fast", read_timeout_seconds=None) assert result.content == [TextContent(type="text", text="fast 3")] - scope.cancel() + scope.cancel() # pragma: no cover # Run server and client in separate task groups to avoid cancellation - server_writer, server_reader = anyio.create_memory_object_stream(1) - client_writer, client_reader = anyio.create_memory_object_stream(1) + server_writer, server_reader = anyio.create_memory_object_stream[SessionMessage](1) + client_writer, client_reader = anyio.create_memory_object_stream[SessionMessage](1) async with anyio.create_task_group() as tg: scope = await tg.start(server_handler, server_reader, client_writer) diff --git a/tests/issues/test_malformed_input.py b/tests/issues/test_malformed_input.py new file mode 100644 index 0000000000..078beb7a58 --- /dev/null +++ b/tests/issues/test_malformed_input.py @@ -0,0 +1,162 @@ +# Claude Debug +"""Test for HackerOne vulnerability report #3156202 - malformed input DOS.""" + +from typing import Any + +import anyio +import pytest + +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.message import SessionMessage +from mcp.types import ( + INVALID_PARAMS, + JSONRPCError, + JSONRPCMessage, + JSONRPCRequest, + ServerCapabilities, +) + + +@pytest.mark.anyio +async def test_malformed_initialize_request_does_not_crash_server(): + """ + Test that malformed initialize requests return proper error responses + instead of crashing the server (HackerOne #3156202). + """ + # Create in-memory streams for testing + read_send_stream, read_receive_stream = anyio.create_memory_object_stream[SessionMessage | Exception](10) + write_send_stream, write_receive_stream = anyio.create_memory_object_stream[SessionMessage](10) + + try: + # Create a malformed initialize request (missing required params field) + malformed_request = JSONRPCRequest( + jsonrpc="2.0", + id="f20fe86132ed4cd197f89a7134de5685", + method="initialize", + # params=None # Missing required params field + ) + + # Wrap in session message + request_message = SessionMessage(message=JSONRPCMessage(malformed_request)) + + # Start a server session + async with ServerSession( + read_stream=read_receive_stream, + write_stream=write_send_stream, + init_options=InitializationOptions( + server_name="test_server", + server_version="1.0.0", + capabilities=ServerCapabilities(), + ), + ): + # Send the malformed request + await read_send_stream.send(request_message) + + # Give the session time to process the request + await anyio.sleep(0.1) + + # Check that we received an error response instead of a crash + try: + response_message = write_receive_stream.receive_nowait() + response = response_message.message.root + + # Verify it's a proper JSON-RPC error response + assert isinstance(response, JSONRPCError) + assert response.jsonrpc == "2.0" + assert response.id == "f20fe86132ed4cd197f89a7134de5685" + assert response.error.code == INVALID_PARAMS + assert "Invalid request parameters" in response.error.message + + # Verify the session is still alive and can handle more requests + # Send another malformed request to confirm server stability + another_malformed_request = JSONRPCRequest( + jsonrpc="2.0", + id="test_id_2", + method="tools/call", + # params=None # Missing required params + ) + another_request_message = SessionMessage(message=JSONRPCMessage(another_malformed_request)) + + await read_send_stream.send(another_request_message) + await anyio.sleep(0.1) + + # Should get another error response, not a crash + second_response_message = write_receive_stream.receive_nowait() + second_response = second_response_message.message.root + + assert isinstance(second_response, JSONRPCError) + assert second_response.id == "test_id_2" + assert second_response.error.code == INVALID_PARAMS + + except anyio.WouldBlock: # pragma: no cover + pytest.fail("No response received - server likely crashed") + finally: # pragma: no cover + # Close all streams to ensure proper cleanup + await read_send_stream.aclose() + await write_send_stream.aclose() + await read_receive_stream.aclose() + await write_receive_stream.aclose() + + +@pytest.mark.anyio +async def test_multiple_concurrent_malformed_requests(): + """ + Test that multiple concurrent malformed requests don't crash the server. + """ + # Create in-memory streams for testing + read_send_stream, read_receive_stream = anyio.create_memory_object_stream[SessionMessage | Exception](100) + write_send_stream, write_receive_stream = anyio.create_memory_object_stream[SessionMessage](100) + + try: + # Start a server session + async with ServerSession( + read_stream=read_receive_stream, + write_stream=write_send_stream, + init_options=InitializationOptions( + server_name="test_server", + server_version="1.0.0", + capabilities=ServerCapabilities(), + ), + ): + # Send multiple malformed requests concurrently + malformed_requests: list[SessionMessage] = [] + for i in range(10): + malformed_request = JSONRPCRequest( + jsonrpc="2.0", + id=f"malformed_{i}", + method="initialize", + # params=None # Missing required params + ) + request_message = SessionMessage(message=JSONRPCMessage(malformed_request)) + malformed_requests.append(request_message) + + # Send all requests + for request in malformed_requests: + await read_send_stream.send(request) + + # Give time to process + await anyio.sleep(0.2) + + # Verify we get error responses for all requests + error_responses: list[Any] = [] + try: + while True: + response_message = write_receive_stream.receive_nowait() + error_responses.append(response_message.message.root) + except anyio.WouldBlock: + pass # No more messages + + # Should have received 10 error responses + assert len(error_responses) == 10 + + for i, response in enumerate(error_responses): + assert isinstance(response, JSONRPCError) + assert response.id == f"malformed_{i}" + assert response.error.code == INVALID_PARAMS + finally: # pragma: no cover + # Close all streams to ensure proper cleanup + await read_send_stream.aclose() + await write_send_stream.aclose() + await read_receive_stream.aclose() + await write_receive_stream.aclose() diff --git a/tests/server/auth/middleware/test_auth_context.py b/tests/server/auth/middleware/test_auth_context.py new file mode 100644 index 0000000000..1cca4df5ab --- /dev/null +++ b/tests/server/auth/middleware/test_auth_context.py @@ -0,0 +1,122 @@ +""" +Tests for the AuthContext middleware components. +""" + +import time + +import pytest +from starlette.types import Message, Receive, Scope, Send + +from mcp.server.auth.middleware.auth_context import ( + AuthContextMiddleware, + auth_context_var, + get_access_token, +) +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.auth.provider import AccessToken + + +class MockApp: + """Mock ASGI app for testing.""" + + def __init__(self): + self.called = False + self.scope: Scope | None = None + self.receive: Receive | None = None + self.send: Send | None = None + self.access_token_during_call: AccessToken | None = None + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + self.called = True + self.scope = scope + self.receive = receive + self.send = send + # Check the context during the call + self.access_token_during_call = get_access_token() + + +@pytest.fixture +def valid_access_token() -> AccessToken: + """Create a valid access token.""" + return AccessToken( + token="valid_token", + client_id="test_client", + scopes=["read", "write"], + expires_at=int(time.time()) + 3600, # 1 hour from now + ) + + +@pytest.mark.anyio +class TestAuthContextMiddleware: + """Tests for the AuthContextMiddleware class.""" + + async def test_with_authenticated_user(self, valid_access_token: AccessToken): + """Test middleware with an authenticated user in scope.""" + app = MockApp() + middleware = AuthContextMiddleware(app) + + # Create an authenticated user + user = AuthenticatedUser(valid_access_token) + + scope: Scope = {"type": "http", "user": user} + + # Create dummy async functions for receive and send + async def receive() -> Message: # pragma: no cover + return {"type": "http.request"} + + async def send(message: Message) -> None: # pragma: no cover + pass + + # Verify context is empty before middleware + assert auth_context_var.get() is None + assert get_access_token() is None + + # Run the middleware + await middleware(scope, receive, send) + + # Verify the app was called + assert app.called + assert app.scope == scope + assert app.receive == receive + assert app.send == send + + # Verify the access token was available during the call + assert app.access_token_during_call == valid_access_token + + # Verify context is reset after middleware + assert auth_context_var.get() is None + assert get_access_token() is None + + async def test_with_no_user(self): + """Test middleware with no user in scope.""" + app = MockApp() + middleware = AuthContextMiddleware(app) + + scope: Scope = {"type": "http"} # No user + + # Create dummy async functions for receive and send + async def receive() -> Message: # pragma: no cover + return {"type": "http.request"} + + async def send(message: Message) -> None: # pragma: no cover + pass + + # Verify context is empty before middleware + assert auth_context_var.get() is None + assert get_access_token() is None + + # Run the middleware + await middleware(scope, receive, send) + + # Verify the app was called + assert app.called + assert app.scope == scope + assert app.receive == receive + assert app.send == send + + # Verify the access token was not available during the call + assert app.access_token_during_call is None + + # Verify context is still empty after middleware + assert auth_context_var.get() is None + assert get_access_token() is None diff --git a/tests/server/auth/middleware/test_bearer_auth.py b/tests/server/auth/middleware/test_bearer_auth.py new file mode 100644 index 0000000000..e13ab96390 --- /dev/null +++ b/tests/server/auth/middleware/test_bearer_auth.py @@ -0,0 +1,450 @@ +""" +Tests for the BearerAuth middleware components. +""" + +import time +from typing import Any, cast + +import pytest +from starlette.authentication import AuthCredentials +from starlette.datastructures import Headers +from starlette.requests import Request +from starlette.types import Message, Receive, Scope, Send + +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, BearerAuthBackend, RequireAuthMiddleware +from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider, ProviderTokenVerifier + + +class MockOAuthProvider: + """Mock OAuth provider for testing. + + This is a simplified version that only implements the methods needed for testing + the BearerAuthMiddleware components. + """ + + def __init__(self): + self.tokens: dict[str, AccessToken] = {} # token -> AccessToken + + def add_token(self, token: str, access_token: AccessToken) -> None: + """Add a token to the provider.""" + self.tokens[token] = access_token + + async def load_access_token(self, token: str) -> AccessToken | None: + """Load an access token.""" + return self.tokens.get(token) + + +def add_token_to_provider( + provider: OAuthAuthorizationServerProvider[Any, Any, Any], + token: str, + access_token: AccessToken, +) -> None: + """Helper function to add a token to a provider. + + This is used to work around type checking issues with our mock provider. + """ + # We know this is actually a MockOAuthProvider + mock_provider = cast(MockOAuthProvider, provider) + mock_provider.add_token(token, access_token) + + +class MockApp: + """Mock ASGI app for testing.""" + + def __init__(self): + self.called = False + self.scope: Scope | None = None + self.receive: Receive | None = None + self.send: Send | None = None + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + self.called = True + self.scope = scope + self.receive = receive + self.send = send + + +@pytest.fixture +def mock_oauth_provider() -> OAuthAuthorizationServerProvider[Any, Any, Any]: + """Create a mock OAuth provider.""" + # Use type casting to satisfy the type checker + return cast(OAuthAuthorizationServerProvider[Any, Any, Any], MockOAuthProvider()) + + +@pytest.fixture +def valid_access_token() -> AccessToken: + """Create a valid access token.""" + return AccessToken( + token="valid_token", + client_id="test_client", + scopes=["read", "write"], + expires_at=int(time.time()) + 3600, # 1 hour from now + ) + + +@pytest.fixture +def expired_access_token() -> AccessToken: + """Create an expired access token.""" + return AccessToken( + token="expired_token", + client_id="test_client", + scopes=["read"], + expires_at=int(time.time()) - 3600, # 1 hour ago + ) + + +@pytest.fixture +def no_expiry_access_token() -> AccessToken: + """Create an access token with no expiry.""" + return AccessToken( + token="no_expiry_token", + client_id="test_client", + scopes=["read", "write"], + expires_at=None, + ) + + +@pytest.mark.anyio +class TestBearerAuthBackend: + """Tests for the BearerAuthBackend class.""" + + async def test_no_auth_header(self, mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any]): + """Test authentication with no Authorization header.""" + backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider)) + request = Request({"type": "http", "headers": []}) + result = await backend.authenticate(request) + assert result is None + + async def test_non_bearer_auth_header(self, mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any]): + """Test authentication with non-Bearer Authorization header.""" + backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider)) + request = Request( + { + "type": "http", + "headers": [(b"authorization", b"Basic dXNlcjpwYXNz")], + } + ) + result = await backend.authenticate(request) + assert result is None + + async def test_invalid_token(self, mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any]): + """Test authentication with invalid token.""" + backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider)) + request = Request( + { + "type": "http", + "headers": [(b"authorization", b"Bearer invalid_token")], + } + ) + result = await backend.authenticate(request) + assert result is None + + async def test_expired_token( + self, + mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any], + expired_access_token: AccessToken, + ): + """Test authentication with expired token.""" + backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider)) + add_token_to_provider(mock_oauth_provider, "expired_token", expired_access_token) + request = Request( + { + "type": "http", + "headers": [(b"authorization", b"Bearer expired_token")], + } + ) + result = await backend.authenticate(request) + assert result is None + + async def test_valid_token( + self, + mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any], + valid_access_token: AccessToken, + ): + """Test authentication with valid token.""" + backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider)) + add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token) + request = Request( + { + "type": "http", + "headers": [(b"authorization", b"Bearer valid_token")], + } + ) + result = await backend.authenticate(request) + assert result is not None + credentials, user = result + assert isinstance(credentials, AuthCredentials) + assert isinstance(user, AuthenticatedUser) + assert credentials.scopes == ["read", "write"] + assert user.display_name == "test_client" + assert user.access_token == valid_access_token + assert user.scopes == ["read", "write"] + + async def test_token_without_expiry( + self, + mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any], + no_expiry_access_token: AccessToken, + ): + """Test authentication with token that has no expiry.""" + backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider)) + add_token_to_provider(mock_oauth_provider, "no_expiry_token", no_expiry_access_token) + request = Request( + { + "type": "http", + "headers": [(b"authorization", b"Bearer no_expiry_token")], + } + ) + result = await backend.authenticate(request) + assert result is not None + credentials, user = result + assert isinstance(credentials, AuthCredentials) + assert isinstance(user, AuthenticatedUser) + assert credentials.scopes == ["read", "write"] + assert user.display_name == "test_client" + assert user.access_token == no_expiry_access_token + assert user.scopes == ["read", "write"] + + async def test_lowercase_bearer_prefix( + self, + mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any], + valid_access_token: AccessToken, + ): + """Test with lowercase 'bearer' prefix in Authorization header""" + backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider)) + add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token) + headers = Headers({"Authorization": "bearer valid_token"}) + scope = {"type": "http", "headers": headers.raw} + request = Request(scope) + result = await backend.authenticate(request) + assert result is not None + credentials, user = result + assert isinstance(credentials, AuthCredentials) + assert isinstance(user, AuthenticatedUser) + assert credentials.scopes == ["read", "write"] + assert user.display_name == "test_client" + assert user.access_token == valid_access_token + + async def test_mixed_case_bearer_prefix( + self, + mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any], + valid_access_token: AccessToken, + ): + """Test with mixed 'BeArEr' prefix in Authorization header""" + backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider)) + add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token) + headers = Headers({"authorization": "BeArEr valid_token"}) + scope = {"type": "http", "headers": headers.raw} + request = Request(scope) + result = await backend.authenticate(request) + assert result is not None + credentials, user = result + assert isinstance(credentials, AuthCredentials) + assert isinstance(user, AuthenticatedUser) + assert credentials.scopes == ["read", "write"] + assert user.display_name == "test_client" + assert user.access_token == valid_access_token + + async def test_mixed_case_authorization_header( + self, + mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any], + valid_access_token: AccessToken, + ): + """Test authentication with mixed 'Authorization' header.""" + backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider)) + add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token) + headers = Headers({"AuThOrIzAtIoN": "BeArEr valid_token"}) + scope = {"type": "http", "headers": headers.raw} + request = Request(scope) + result = await backend.authenticate(request) + assert result is not None + credentials, user = result + assert isinstance(credentials, AuthCredentials) + assert isinstance(user, AuthenticatedUser) + assert credentials.scopes == ["read", "write"] + assert user.display_name == "test_client" + assert user.access_token == valid_access_token + + +@pytest.mark.anyio +class TestRequireAuthMiddleware: + """Tests for the RequireAuthMiddleware class.""" + + async def test_no_user(self): + """Test middleware with no user in scope.""" + app = MockApp() + middleware = RequireAuthMiddleware(app, required_scopes=["read"]) + scope: Scope = {"type": "http"} + + # Create dummy async functions for receive and send + async def receive() -> Message: # pragma: no cover + return {"type": "http.request"} + + sent_messages: list[Message] = [] + + async def send(message: Message) -> None: + sent_messages.append(message) + + await middleware(scope, receive, send) + + # Check that a 401 response was sent + assert len(sent_messages) == 2 + assert sent_messages[0]["type"] == "http.response.start" + assert sent_messages[0]["status"] == 401 + assert any(h[0] == b"www-authenticate" for h in sent_messages[0]["headers"]) + assert not app.called + + async def test_non_authenticated_user(self): + """Test middleware with non-authenticated user in scope.""" + app = MockApp() + middleware = RequireAuthMiddleware(app, required_scopes=["read"]) + scope: Scope = {"type": "http", "user": object()} + + # Create dummy async functions for receive and send + async def receive() -> Message: # pragma: no cover + return {"type": "http.request"} + + sent_messages: list[Message] = [] + + async def send(message: Message) -> None: + sent_messages.append(message) + + await middleware(scope, receive, send) + + # Check that a 401 response was sent + assert len(sent_messages) == 2 + assert sent_messages[0]["type"] == "http.response.start" + assert sent_messages[0]["status"] == 401 + assert any(h[0] == b"www-authenticate" for h in sent_messages[0]["headers"]) + assert not app.called + + async def test_missing_required_scope(self, valid_access_token: AccessToken): + """Test middleware with user missing required scope.""" + app = MockApp() + middleware = RequireAuthMiddleware(app, required_scopes=["admin"]) + + # Create a user with read/write scopes but not admin + user = AuthenticatedUser(valid_access_token) + auth = AuthCredentials(["read", "write"]) + + scope: Scope = {"type": "http", "user": user, "auth": auth} + + # Create dummy async functions for receive and send + async def receive() -> Message: # pragma: no cover + return {"type": "http.request"} + + sent_messages: list[Message] = [] + + async def send(message: Message) -> None: + sent_messages.append(message) + + await middleware(scope, receive, send) + + # Check that a 403 response was sent + assert len(sent_messages) == 2 + assert sent_messages[0]["type"] == "http.response.start" + assert sent_messages[0]["status"] == 403 + assert any(h[0] == b"www-authenticate" for h in sent_messages[0]["headers"]) + assert not app.called + + async def test_no_auth_credentials(self, valid_access_token: AccessToken): + """Test middleware with no auth credentials in scope.""" + app = MockApp() + middleware = RequireAuthMiddleware(app, required_scopes=["read"]) + + # Create a user with read/write scopes + user = AuthenticatedUser(valid_access_token) + + scope: Scope = {"type": "http", "user": user} # No auth credentials + + # Create dummy async functions for receive and send + async def receive() -> Message: # pragma: no cover + return {"type": "http.request"} + + sent_messages: list[Message] = [] + + async def send(message: Message) -> None: + sent_messages.append(message) + + await middleware(scope, receive, send) + + # Check that a 403 response was sent + assert len(sent_messages) == 2 + assert sent_messages[0]["type"] == "http.response.start" + assert sent_messages[0]["status"] == 403 + assert any(h[0] == b"www-authenticate" for h in sent_messages[0]["headers"]) + assert not app.called + + async def test_has_required_scopes(self, valid_access_token: AccessToken): + """Test middleware with user having all required scopes.""" + app = MockApp() + middleware = RequireAuthMiddleware(app, required_scopes=["read"]) + + # Create a user with read/write scopes + user = AuthenticatedUser(valid_access_token) + auth = AuthCredentials(["read", "write"]) + + scope: Scope = {"type": "http", "user": user, "auth": auth} + + # Create dummy async functions for receive and send + async def receive() -> Message: # pragma: no cover + return {"type": "http.request"} + + async def send(message: Message) -> None: # pragma: no cover + pass + + await middleware(scope, receive, send) + + assert app.called + assert app.scope == scope + assert app.receive == receive + assert app.send == send + + async def test_multiple_required_scopes(self, valid_access_token: AccessToken): + """Test middleware with multiple required scopes.""" + app = MockApp() + middleware = RequireAuthMiddleware(app, required_scopes=["read", "write"]) + + # Create a user with read/write scopes + user = AuthenticatedUser(valid_access_token) + auth = AuthCredentials(["read", "write"]) + + scope: Scope = {"type": "http", "user": user, "auth": auth} + + # Create dummy async functions for receive and send + async def receive() -> Message: # pragma: no cover + return {"type": "http.request"} + + async def send(message: Message) -> None: # pragma: no cover + pass + + await middleware(scope, receive, send) + + assert app.called + assert app.scope == scope + assert app.receive == receive + assert app.send == send + + async def test_no_required_scopes(self, valid_access_token: AccessToken): + """Test middleware with no required scopes.""" + app = MockApp() + middleware = RequireAuthMiddleware(app, required_scopes=[]) + + # Create a user with read/write scopes + user = AuthenticatedUser(valid_access_token) + auth = AuthCredentials(["read", "write"]) + + scope: Scope = {"type": "http", "user": user, "auth": auth} + + # Create dummy async functions for receive and send + async def receive() -> Message: # pragma: no cover + return {"type": "http.request"} + + async def send(message: Message) -> None: # pragma: no cover + pass + + await middleware(scope, receive, send) + + assert app.called + assert app.scope == scope + assert app.receive == receive + assert app.send == send diff --git a/tests/server/auth/test_error_handling.py b/tests/server/auth/test_error_handling.py new file mode 100644 index 0000000000..f331b2cb2d --- /dev/null +++ b/tests/server/auth/test_error_handling.py @@ -0,0 +1,304 @@ +""" +Tests for OAuth error handling in the auth handlers. +""" + +import unittest.mock +from typing import TYPE_CHECKING, Any +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest +from httpx import ASGITransport +from pydantic import AnyHttpUrl +from starlette.applications import Starlette + +from mcp.server.auth.provider import AuthorizeError, RegistrationError, TokenError +from mcp.server.auth.routes import create_auth_routes + +# TODO(Marcelo): This TYPE_CHECKING shouldn't be here, but pytest doesn't seem to get the module correctly. +if TYPE_CHECKING: + from ...server.fastmcp.auth.test_auth_integration import MockOAuthProvider +else: + from tests.server.fastmcp.auth.test_auth_integration import MockOAuthProvider + + +@pytest.fixture +def oauth_provider(): + """Return a MockOAuthProvider instance that can be configured to raise errors.""" + return MockOAuthProvider() + + +@pytest.fixture +def app(oauth_provider: MockOAuthProvider): + from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions + + # Enable client registration + client_registration_options = ClientRegistrationOptions(enabled=True) + revocation_options = RevocationOptions(enabled=True) + + # Create auth routes + auth_routes = create_auth_routes( + oauth_provider, + issuer_url=AnyHttpUrl("http://localhost"), + client_registration_options=client_registration_options, + revocation_options=revocation_options, + ) + + # Create Starlette app with routes directly + return Starlette(routes=auth_routes) + + +@pytest.fixture +def client(app: Starlette): + transport = ASGITransport(app=app) + # Use base_url without a path since routes are directly on the app + return httpx.AsyncClient(transport=transport, base_url="http://localhost") + + +@pytest.fixture +def pkce_challenge(): + """Create a PKCE challenge with code_verifier and code_challenge.""" + import base64 + import hashlib + import secrets + + # Generate a code verifier + code_verifier = secrets.token_urlsafe(64)[:128] + + # Create code challenge using S256 method + code_verifier_bytes = code_verifier.encode("ascii") + sha256 = hashlib.sha256(code_verifier_bytes).digest() + code_challenge = base64.urlsafe_b64encode(sha256).decode().rstrip("=") + + return {"code_verifier": code_verifier, "code_challenge": code_challenge} + + +@pytest.fixture +async def registered_client(client: httpx.AsyncClient) -> dict[str, Any]: + """Create and register a test client.""" + # Default client metadata + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "client_name": "Test Client", + } + + response = await client.post("/register", json=client_metadata) + assert response.status_code == 201, f"Failed to register client: {response.content}" + + client_info = response.json() + return client_info + + +class TestRegistrationErrorHandling: + @pytest.mark.anyio + async def test_registration_error_handling(self, client: httpx.AsyncClient, oauth_provider: MockOAuthProvider): + # Mock the register_client method to raise a registration error + with unittest.mock.patch.object( + oauth_provider, + "register_client", + side_effect=RegistrationError( + error="invalid_redirect_uri", + error_description="The redirect URI is invalid", + ), + ): + # Prepare a client registration request + client_data = { + "redirect_uris": ["https://client.example.com/callback"], + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "client_name": "Test Client", + } + + # Send the registration request + response = await client.post( + "/register", + json=client_data, + ) + + # Verify the response + assert response.status_code == 400, response.content + data = response.json() + assert data["error"] == "invalid_redirect_uri" + assert data["error_description"] == "The redirect URI is invalid" + + +class TestAuthorizeErrorHandling: + @pytest.mark.anyio + async def test_authorize_error_handling( + self, + client: httpx.AsyncClient, + oauth_provider: MockOAuthProvider, + registered_client: dict[str, Any], + pkce_challenge: dict[str, str], + ): + # Mock the authorize method to raise an authorize error + with unittest.mock.patch.object( + oauth_provider, + "authorize", + side_effect=AuthorizeError(error="access_denied", error_description="The user denied the request"), + ): + # Register the client + client_id = registered_client["client_id"] + redirect_uri = registered_client["redirect_uris"][0] + + # Prepare an authorization request + params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + "state": "test_state", + } + + # Send the authorization request + response = await client.get("/authorize", params=params) + + # Verify the response is a redirect with error parameters + assert response.status_code == 302 + redirect_url = response.headers["location"] + parsed_url = urlparse(redirect_url) + query_params = parse_qs(parsed_url.query) + + assert query_params["error"][0] == "access_denied" + assert "error_description" in query_params + assert query_params["state"][0] == "test_state" + + +class TestTokenErrorHandling: + @pytest.mark.anyio + async def test_token_error_handling_auth_code( + self, + client: httpx.AsyncClient, + oauth_provider: MockOAuthProvider, + registered_client: dict[str, Any], + pkce_challenge: dict[str, str], + ): + # Register the client and get an auth code + client_id = registered_client["client_id"] + client_secret = registered_client["client_secret"] + redirect_uri = registered_client["redirect_uris"][0] + + # First get an authorization code + auth_response = await client.get( + "/authorize", + params={ + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + "state": "test_state", + }, + ) + + redirect_url = auth_response.headers["location"] + parsed_url = urlparse(redirect_url) + query_params = parse_qs(parsed_url.query) + code = query_params["code"][0] + + # Mock the exchange_authorization_code method to raise a token error + with unittest.mock.patch.object( + oauth_provider, + "exchange_authorization_code", + side_effect=TokenError( + error="invalid_grant", + error_description="The authorization code is invalid", + ), + ): + # Try to exchange the code for tokens + token_response = await client.post( + "/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": client_id, + "client_secret": client_secret, + "code_verifier": pkce_challenge["code_verifier"], + }, + ) + + # Verify the response + assert token_response.status_code == 400 + data = token_response.json() + assert data["error"] == "invalid_grant" + assert data["error_description"] == "The authorization code is invalid" + + @pytest.mark.anyio + async def test_token_error_handling_refresh_token( + self, + client: httpx.AsyncClient, + oauth_provider: MockOAuthProvider, + registered_client: dict[str, Any], + pkce_challenge: dict[str, str], + ): + # Register the client and get tokens + client_id = registered_client["client_id"] + client_secret = registered_client["client_secret"] + redirect_uri = registered_client["redirect_uris"][0] + + # First get an authorization code + auth_response = await client.get( + "/authorize", + params={ + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + "state": "test_state", + }, + ) + assert auth_response.status_code == 302, auth_response.content + + redirect_url = auth_response.headers["location"] + parsed_url = urlparse(redirect_url) + query_params = parse_qs(parsed_url.query) + code = query_params["code"][0] + + # Exchange the code for tokens + token_response = await client.post( + "/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": client_id, + "client_secret": client_secret, + "code_verifier": pkce_challenge["code_verifier"], + }, + ) + + tokens = token_response.json() + refresh_token = tokens["refresh_token"] + + # Mock the exchange_refresh_token method to raise a token error + with unittest.mock.patch.object( + oauth_provider, + "exchange_refresh_token", + side_effect=TokenError( + error="invalid_scope", + error_description="The requested scope is invalid", + ), + ): + # Try to use the refresh token + refresh_response = await client.post( + "/token", + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + "client_secret": client_secret, + }, + ) + + # Verify the response + assert refresh_response.status_code == 400 + data = refresh_response.json() + assert data["error"] == "invalid_scope" + assert data["error_description"] == "The requested scope is invalid" diff --git a/tests/server/auth/test_protected_resource.py b/tests/server/auth/test_protected_resource.py new file mode 100644 index 0000000000..82af16c5b1 --- /dev/null +++ b/tests/server/auth/test_protected_resource.py @@ -0,0 +1,198 @@ +""" +Integration tests for MCP Oauth Protected Resource. +""" + +import httpx +import pytest +from inline_snapshot import snapshot +from pydantic import AnyHttpUrl +from starlette.applications import Starlette + +from mcp.server.auth.routes import build_resource_metadata_url, create_protected_resource_routes + + +@pytest.fixture +def test_app(): + """Fixture to create protected resource routes for testing.""" + + # Create the protected resource routes + protected_resource_routes = create_protected_resource_routes( + resource_url=AnyHttpUrl("https://example.com/resource"), + authorization_servers=[AnyHttpUrl("https://auth.example.com/authorization")], + scopes_supported=["read", "write"], + resource_name="Example Resource", + resource_documentation=AnyHttpUrl("https://docs.example.com/resource"), + ) + + app = Starlette(routes=protected_resource_routes) + return app + + +@pytest.fixture +async def test_client(test_app: Starlette): + """Fixture to create an HTTP client for the protected resource app.""" + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=test_app), base_url="https://mcptest.com") as client: + yield client + + +@pytest.mark.anyio +async def test_metadata_endpoint_with_path(test_client: httpx.AsyncClient): + """Test the OAuth 2.0 Protected Resource metadata endpoint for path-based resource.""" + + # For resource with path "/resource", metadata should be accessible at the path-aware location + response = await test_client.get("/.well-known/oauth-protected-resource/resource") + assert response.json() == snapshot( + { + "resource": "https://example.com/resource", + "authorization_servers": ["https://auth.example.com/authorization"], + "scopes_supported": ["read", "write"], + "resource_name": "Example Resource", + "resource_documentation": "https://docs.example.com/resource", + "bearer_methods_supported": ["header"], + } + ) + + +@pytest.mark.anyio +async def test_metadata_endpoint_root_path_returns_404(test_client: httpx.AsyncClient): + """Test that root path returns 404 for path-based resource.""" + + # Root path should return 404 for path-based resources + response = await test_client.get("/.well-known/oauth-protected-resource") + assert response.status_code == 404 + + +@pytest.fixture +def root_resource_app(): + """Fixture to create protected resource routes for root-level resource.""" + + # Create routes for a resource without path component + protected_resource_routes = create_protected_resource_routes( + resource_url=AnyHttpUrl("https://example.com"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + scopes_supported=["read"], + resource_name="Root Resource", + ) + + app = Starlette(routes=protected_resource_routes) + return app + + +@pytest.fixture +async def root_resource_client(root_resource_app: Starlette): + """Fixture to create an HTTP client for the root resource app.""" + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=root_resource_app), base_url="https://mcptest.com" + ) as client: + yield client + + +@pytest.mark.anyio +async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncClient): + """Test metadata endpoint for root-level resource.""" + + # For root resource, metadata should be at standard location + response = await root_resource_client.get("/.well-known/oauth-protected-resource") + assert response.status_code == 200 + assert response.json() == snapshot( + { + "resource": "https://example.com/", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["read"], + "resource_name": "Root Resource", + "bearer_methods_supported": ["header"], + } + ) + + +class TestMetadataUrlConstruction: + """Test URL construction utility function.""" + + def test_url_without_path(self): + """Test URL construction for resource without path component.""" + resource_url = AnyHttpUrl("https://example.com") + result = build_resource_metadata_url(resource_url) + assert str(result) == "https://example.com/.well-known/oauth-protected-resource" + + def test_url_with_path_component(self): + """Test URL construction for resource with path component.""" + resource_url = AnyHttpUrl("https://example.com/mcp") + result = build_resource_metadata_url(resource_url) + assert str(result) == "https://example.com/.well-known/oauth-protected-resource/mcp" + + def test_url_with_trailing_slash_only(self): + """Test URL construction for resource with trailing slash only.""" + resource_url = AnyHttpUrl("https://example.com/") + result = build_resource_metadata_url(resource_url) + # Trailing slash should be treated as empty path + assert str(result) == "https://example.com/.well-known/oauth-protected-resource" + + @pytest.mark.parametrize( + "resource_url,expected_url", + [ + ("https://example.com", "https://example.com/.well-known/oauth-protected-resource"), + ("https://example.com/", "https://example.com/.well-known/oauth-protected-resource"), + ("https://example.com/mcp", "https://example.com/.well-known/oauth-protected-resource/mcp"), + ("http://localhost:8001/mcp", "http://localhost:8001/.well-known/oauth-protected-resource/mcp"), + ], + ) + def test_various_resource_configurations(self, resource_url: str, expected_url: str): + """Test URL construction with various resource configurations.""" + result = build_resource_metadata_url(AnyHttpUrl(resource_url)) + assert str(result) == expected_url + + +class TestRouteConsistency: + """Test consistency between URL generation and route registration.""" + + def test_route_path_matches_metadata_url(self): + """Test that route path matches the generated metadata URL.""" + resource_url = AnyHttpUrl("https://example.com/mcp") + + # Generate metadata URL + metadata_url = build_resource_metadata_url(resource_url) + + # Create routes + routes = create_protected_resource_routes( + resource_url=resource_url, + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + + # Extract path from metadata URL + from urllib.parse import urlparse + + metadata_path = urlparse(str(metadata_url)).path + + # Verify consistency + assert len(routes) == 1 + assert routes[0].path == metadata_path + + @pytest.mark.parametrize( + "resource_url,expected_path", + [ + ("https://example.com", "/.well-known/oauth-protected-resource"), + ("https://example.com/", "/.well-known/oauth-protected-resource"), + ("https://example.com/mcp", "/.well-known/oauth-protected-resource/mcp"), + ], + ) + def test_consistent_paths_for_various_resources(self, resource_url: str, expected_path: str): + """Test that URL generation and route creation are consistent.""" + resource_url_obj = AnyHttpUrl(resource_url) + + # Test URL generation + metadata_url = build_resource_metadata_url(resource_url_obj) + from urllib.parse import urlparse + + url_path = urlparse(str(metadata_url)).path + + # Test route creation + routes = create_protected_resource_routes( + resource_url=resource_url_obj, + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + route_path = routes[0].path + + # Both should match expected path + assert url_path == expected_path + assert route_path == expected_path + assert url_path == route_path diff --git a/tests/server/auth/test_provider.py b/tests/server/auth/test_provider.py new file mode 100644 index 0000000000..7fe6213497 --- /dev/null +++ b/tests/server/auth/test_provider.py @@ -0,0 +1,77 @@ +""" +Tests for mcp.server.auth.provider module. +""" + +from mcp.server.auth.provider import construct_redirect_uri + + +class TestConstructRedirectUri: + """Tests for the construct_redirect_uri function.""" + + def test_construct_redirect_uri_no_existing_params(self): + """Test construct_redirect_uri with no existing query parameters.""" + base_uri = "http://localhost:8000/callback" + result = construct_redirect_uri(base_uri, code="auth_code", state="test_state") + + assert "http://localhost:8000/callback?code=auth_code&state=test_state" == result + + def test_construct_redirect_uri_with_existing_params(self): + """Test construct_redirect_uri with existing query parameters (regression test for #1279).""" + base_uri = "http://localhost:8000/callback?session_id=1234" + result = construct_redirect_uri(base_uri, code="auth_code", state="test_state") + + # Should preserve existing params and add new ones + assert "session_id=1234" in result + assert "code=auth_code" in result + assert "state=test_state" in result + assert result.startswith("http://localhost:8000/callback?") + + def test_construct_redirect_uri_multiple_existing_params(self): + """Test construct_redirect_uri with multiple existing query parameters.""" + base_uri = "http://localhost:8000/callback?session_id=1234&user=test" + result = construct_redirect_uri(base_uri, code="auth_code") + + assert "session_id=1234" in result + assert "user=test" in result + assert "code=auth_code" in result + + def test_construct_redirect_uri_with_none_values(self): + """Test construct_redirect_uri filters out None values.""" + base_uri = "http://localhost:8000/callback" + result = construct_redirect_uri(base_uri, code="auth_code", state=None) + + assert result == "http://localhost:8000/callback?code=auth_code" + assert "state" not in result + + def test_construct_redirect_uri_empty_params(self): + """Test construct_redirect_uri with no additional parameters.""" + base_uri = "http://localhost:8000/callback?existing=param" + result = construct_redirect_uri(base_uri) + + assert result == "http://localhost:8000/callback?existing=param" + + def test_construct_redirect_uri_duplicate_param_names(self): + """Test construct_redirect_uri when adding param that already exists.""" + base_uri = "http://localhost:8000/callback?code=existing" + result = construct_redirect_uri(base_uri, code="new_code") + + # Should contain both values (this is expected behavior of parse_qs/urlencode) + assert "code=existing" in result + assert "code=new_code" in result + + def test_construct_redirect_uri_multivalued_existing_params(self): + """Test construct_redirect_uri with existing multi-valued parameters.""" + base_uri = "http://localhost:8000/callback?scope=read&scope=write" + result = construct_redirect_uri(base_uri, code="auth_code") + + assert "scope=read" in result + assert "scope=write" in result + assert "code=auth_code" in result + + def test_construct_redirect_uri_encoded_values(self): + """Test construct_redirect_uri handles URL encoding properly.""" + base_uri = "http://localhost:8000/callback" + result = construct_redirect_uri(base_uri, state="test state with spaces") + + # urlencode uses + for spaces by default + assert "state=test+state+with+spaces" in result diff --git a/tests/server/fastmcp/auth/__init__.py b/tests/server/fastmcp/auth/__init__.py new file mode 100644 index 0000000000..64d318ec46 --- /dev/null +++ b/tests/server/fastmcp/auth/__init__.py @@ -0,0 +1,3 @@ +""" +Tests for the MCP server auth components. +""" diff --git a/tests/server/fastmcp/auth/test_auth_integration.py b/tests/server/fastmcp/auth/test_auth_integration.py new file mode 100644 index 0000000000..7342013a81 --- /dev/null +++ b/tests/server/fastmcp/auth/test_auth_integration.py @@ -0,0 +1,1632 @@ +""" +Integration tests for MCP authorization components. +""" + +import base64 +import hashlib +import secrets +import time +import unittest.mock +from typing import Any +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest +from pydantic import AnyHttpUrl, AnyUrl +from starlette.applications import Starlette + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + OAuthAuthorizationServerProvider, + RefreshToken, + construct_redirect_uri, +) +from mcp.server.auth.routes import ClientRegistrationOptions, RevocationOptions, create_auth_routes +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +# Mock OAuth provider for testing +class MockOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): + def __init__(self): + self.clients: dict[str, OAuthClientInformationFull] = {} + self.auth_codes: dict[str, AuthorizationCode] = {} # code -> {client_id, code_challenge, redirect_uri} + self.tokens: dict[str, AccessToken] = {} # token -> {client_id, scopes, expires_at} + self.refresh_tokens: dict[str, str] = {} # refresh_token -> access_token + + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + return self.clients.get(client_id) + + async def register_client(self, client_info: OAuthClientInformationFull): + assert client_info.client_id is not None + self.clients[client_info.client_id] = client_info + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + # toy authorize implementation which just immediately generates an authorization + # code and completes the redirect + assert client.client_id is not None + code = AuthorizationCode( + code=f"code_{int(time.time())}", + client_id=client.client_id, + code_challenge=params.code_challenge, + redirect_uri=params.redirect_uri, + redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly, + expires_at=time.time() + 300, + scopes=params.scopes or ["read", "write"], + ) + self.auth_codes[code.code] = code + + return construct_redirect_uri(str(params.redirect_uri), code=code.code, state=params.state) + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> AuthorizationCode | None: + return self.auth_codes.get(authorization_code) + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + assert authorization_code.code in self.auth_codes + + # Generate an access token and refresh token + access_token = f"access_{secrets.token_hex(32)}" + refresh_token = f"refresh_{secrets.token_hex(32)}" + + # Store the tokens + assert client.client_id is not None + self.tokens[access_token] = AccessToken( + token=access_token, + client_id=client.client_id, + scopes=authorization_code.scopes, + expires_at=int(time.time()) + 3600, + ) + + self.refresh_tokens[refresh_token] = access_token + + # Remove the used code + del self.auth_codes[authorization_code.code] + + return OAuthToken( + access_token=access_token, + token_type="Bearer", + expires_in=3600, + scope="read write", + refresh_token=refresh_token, + ) + + async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None: + old_access_token = self.refresh_tokens.get(refresh_token) + if old_access_token is None: + return None + token_info = self.tokens.get(old_access_token) + if token_info is None: # pragma: no cover + return None + + # Create a RefreshToken object that matches what is expected in later code + refresh_obj = RefreshToken( + token=refresh_token, + client_id=token_info.client_id, + scopes=token_info.scopes, + expires_at=token_info.expires_at, + ) + + return refresh_obj + + async def exchange_refresh_token( + self, + client: OAuthClientInformationFull, + refresh_token: RefreshToken, + scopes: list[str], + ) -> OAuthToken: + # Check if refresh token exists + assert refresh_token.token in self.refresh_tokens + + old_access_token = self.refresh_tokens[refresh_token.token] + + # Check if the access token exists + assert old_access_token in self.tokens + + # Check if the token was issued to this client + token_info = self.tokens[old_access_token] + assert token_info.client_id == client.client_id + + # Generate a new access token and refresh token + new_access_token = f"access_{secrets.token_hex(32)}" + new_refresh_token = f"refresh_{secrets.token_hex(32)}" + + # Store the new tokens + assert client.client_id is not None + self.tokens[new_access_token] = AccessToken( + token=new_access_token, + client_id=client.client_id, + scopes=scopes or token_info.scopes, + expires_at=int(time.time()) + 3600, + ) + + self.refresh_tokens[new_refresh_token] = new_access_token + + # Remove the old tokens + del self.refresh_tokens[refresh_token.token] + del self.tokens[old_access_token] + + return OAuthToken( + access_token=new_access_token, + token_type="Bearer", + expires_in=3600, + scope=" ".join(scopes) if scopes else " ".join(token_info.scopes), + refresh_token=new_refresh_token, + ) + + async def load_access_token(self, token: str) -> AccessToken | None: + token_info = self.tokens.get(token) + + # Check if token is expired + # if token_info.expires_at < int(time.time()): + # raise InvalidTokenError("Access token has expired") + + return token_info and AccessToken( + token=token, + client_id=token_info.client_id, + scopes=token_info.scopes, + expires_at=token_info.expires_at, + ) + + async def revoke_token(self, token: AccessToken | RefreshToken) -> None: + match token: + case RefreshToken(): # pragma: no cover + # Remove the refresh token + del self.refresh_tokens[token.token] + + case AccessToken(): # pragma: no branch + # Remove the access token + del self.tokens[token.token] + + # Also remove any refresh tokens that point to this access token + for refresh_token, access_token in list(self.refresh_tokens.items()): + if access_token == token.token: # pragma: no branch + del self.refresh_tokens[refresh_token] + + +@pytest.fixture +def mock_oauth_provider(): + return MockOAuthProvider() + + +@pytest.fixture +def auth_app(mock_oauth_provider: MockOAuthProvider): + # Create auth router + auth_routes = create_auth_routes( + mock_oauth_provider, + AnyHttpUrl("https://auth.example.com"), + AnyHttpUrl("https://docs.example.com"), + client_registration_options=ClientRegistrationOptions( + enabled=True, + valid_scopes=["read", "write", "profile"], + default_scopes=["read", "write"], + ), + revocation_options=RevocationOptions(enabled=True), + ) + + # Create Starlette app + app = Starlette(routes=auth_routes) + + return app + + +@pytest.fixture +async def test_client(auth_app: Starlette): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app), base_url="https://mcptest.com") as client: + yield client + + +@pytest.fixture +async def registered_client( + test_client: httpx.AsyncClient, request: pytest.FixtureRequest +) -> OAuthClientInformationFull: + """Create and register a test client. + + Parameters can be customized via indirect parameterization: + @pytest.mark.parametrize("registered_client", + [{"grant_types": ["authorization_code"]}], + indirect=True) + """ + # Default client metadata + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Test Client", + "grant_types": ["authorization_code", "refresh_token"], + } + + # Override with any parameters from the test + if hasattr(request, "param") and request.param: + client_metadata.update(request.param) + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 201, f"Failed to register client: {response.content}" + + client_info = response.json() + return client_info + + +@pytest.fixture +def pkce_challenge(): + """Create a PKCE challenge with code_verifier and code_challenge.""" + code_verifier = "some_random_verifier_string" + code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode().rstrip("=") + + return {"code_verifier": code_verifier, "code_challenge": code_challenge} + + +@pytest.fixture +async def auth_code( + test_client: httpx.AsyncClient, + registered_client: dict[str, Any], + pkce_challenge: dict[str, str], + request: pytest.FixtureRequest, +): + """Get an authorization code. + + Parameters can be customized via indirect parameterization: + @pytest.mark.parametrize("auth_code", + [{"redirect_uri": "https://client.example.com/other-callback"}], + indirect=True) + """ + # Default authorize params + auth_params = { + "response_type": "code", + "client_id": registered_client["client_id"], + "redirect_uri": "https://client.example.com/callback", + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + "state": "test_state", + } + + # Override with any parameters from the test + if hasattr(request, "param") and request.param: # pragma: no cover + auth_params.update(request.param) + + response = await test_client.get("/authorize", params=auth_params) + assert response.status_code == 302, f"Failed to get auth code: {response.content}" + + # Extract the authorization code + redirect_url = response.headers["location"] + parsed_url = urlparse(redirect_url) + query_params = parse_qs(parsed_url.query) + + assert "code" in query_params, f"No code in response: {query_params}" + auth_code = query_params["code"][0] + + return { + "code": auth_code, + "redirect_uri": auth_params["redirect_uri"], + "state": query_params.get("state", [None])[0], + } + + +class TestAuthEndpoints: + @pytest.mark.anyio + async def test_metadata_endpoint(self, test_client: httpx.AsyncClient): + """Test the OAuth 2.0 metadata endpoint.""" + + response = await test_client.get("/.well-known/oauth-authorization-server") + assert response.status_code == 200 + + metadata = response.json() + assert metadata["issuer"] == "https://auth.example.com/" + assert metadata["authorization_endpoint"] == "https://auth.example.com/authorize" + assert metadata["token_endpoint"] == "https://auth.example.com/token" + assert metadata["registration_endpoint"] == "https://auth.example.com/register" + assert metadata["revocation_endpoint"] == "https://auth.example.com/revoke" + assert metadata["response_types_supported"] == ["code"] + assert metadata["code_challenge_methods_supported"] == ["S256"] + assert metadata["token_endpoint_auth_methods_supported"] == ["client_secret_post", "client_secret_basic"] + assert metadata["grant_types_supported"] == [ + "authorization_code", + "refresh_token", + ] + assert metadata["service_documentation"] == "https://docs.example.com/" + + @pytest.mark.anyio + async def test_token_validation_error(self, test_client: httpx.AsyncClient): + """Test token endpoint error - validation error.""" + # Missing required fields + response = await test_client.post( + "/token", + data={ + "grant_type": "authorization_code", + # Missing code, code_verifier, client_id, etc. + }, + ) + error_response = response.json() + # Per RFC 6749 Section 5.2, authentication failures (missing client_id) + # must return "invalid_client", not "unauthorized_client" + assert error_response["error"] == "invalid_client" + assert "error_description" in error_response # Contains error message + + @pytest.mark.anyio + async def test_token_invalid_client_secret_returns_invalid_client( + self, + test_client: httpx.AsyncClient, + registered_client: dict[str, Any], + pkce_challenge: dict[str, str], + mock_oauth_provider: MockOAuthProvider, + ): + """Test token endpoint returns 'invalid_client' for wrong client_secret per RFC 6749. + + RFC 6749 Section 5.2 defines: + - invalid_client: Client authentication failed (wrong credentials, unknown client) + - unauthorized_client: Authenticated client not authorized for grant type + + When client_secret is wrong, this is an authentication failure, so the + error code MUST be 'invalid_client'. + """ + # Create an auth code for the registered client + auth_code = f"code_{int(time.time())}" + mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode( + code=auth_code, + client_id=registered_client["client_id"], + code_challenge=pkce_challenge["code_challenge"], + redirect_uri=AnyUrl("https://client.example.com/callback"), + redirect_uri_provided_explicitly=True, + scopes=["read", "write"], + expires_at=time.time() + 600, + ) + + # Try to exchange the auth code with a WRONG client_secret + response = await test_client.post( + "/token", + data={ + "grant_type": "authorization_code", + "client_id": registered_client["client_id"], + "client_secret": "wrong_secret_that_does_not_match", + "code": auth_code, + "code_verifier": pkce_challenge["code_verifier"], + "redirect_uri": "https://client.example.com/callback", + }, + ) + + assert response.status_code == 401 + error_response = response.json() + # RFC 6749 Section 5.2: authentication failures MUST return "invalid_client" + assert error_response["error"] == "invalid_client" + assert "Invalid client_secret" in error_response["error_description"] + + @pytest.mark.anyio + async def test_token_invalid_auth_code( + self, + test_client: httpx.AsyncClient, + registered_client: dict[str, Any], + pkce_challenge: dict[str, str], + ): + """Test token endpoint error - authorization code does not exist.""" + # Try to use a non-existent authorization code + response = await test_client.post( + "/token", + data={ + "grant_type": "authorization_code", + "client_id": registered_client["client_id"], + "client_secret": registered_client["client_secret"], + "code": "non_existent_auth_code", + "code_verifier": pkce_challenge["code_verifier"], + "redirect_uri": "https://client.example.com/callback", + }, + ) + + assert response.status_code == 400 + error_response = response.json() + assert error_response["error"] == "invalid_grant" + assert "authorization code does not exist" in error_response["error_description"] + + @pytest.mark.anyio + async def test_token_expired_auth_code( + self, + test_client: httpx.AsyncClient, + registered_client: dict[str, Any], + auth_code: dict[str, str], + pkce_challenge: dict[str, str], + mock_oauth_provider: MockOAuthProvider, + ): + """Test token endpoint error - authorization code has expired.""" + # Get the current time for our time mocking + current_time = time.time() + + # Find the auth code object + code_value = auth_code["code"] + found_code = None + for code_obj in mock_oauth_provider.auth_codes.values(): # pragma: no branch + if code_obj.code == code_value: # pragma: no branch + found_code = code_obj + break + + assert found_code is not None + + # Authorization codes are typically short-lived (5 minutes = 300 seconds) + # So we'll mock time to be 10 minutes (600 seconds) in the future + with unittest.mock.patch("time.time", return_value=current_time + 600): + # Try to use the expired authorization code + response = await test_client.post( + "/token", + data={ + "grant_type": "authorization_code", + "client_id": registered_client["client_id"], + "client_secret": registered_client["client_secret"], + "code": code_value, + "code_verifier": pkce_challenge["code_verifier"], + "redirect_uri": auth_code["redirect_uri"], + }, + ) + assert response.status_code == 400 + error_response = response.json() + assert error_response["error"] == "invalid_grant" + assert "authorization code has expired" in error_response["error_description"] + + @pytest.mark.anyio + @pytest.mark.parametrize( + "registered_client", + [ + { + "redirect_uris": [ + "https://client.example.com/callback", + "https://client.example.com/other-callback", + ] + } + ], + indirect=True, + ) + async def test_token_redirect_uri_mismatch( + self, + test_client: httpx.AsyncClient, + registered_client: dict[str, Any], + auth_code: dict[str, str], + pkce_challenge: dict[str, str], + ): + """Test token endpoint error - redirect URI mismatch.""" + # Try to use the code with a different redirect URI + response = await test_client.post( + "/token", + data={ + "grant_type": "authorization_code", + "client_id": registered_client["client_id"], + "client_secret": registered_client["client_secret"], + "code": auth_code["code"], + "code_verifier": pkce_challenge["code_verifier"], + # Different from the one used in /authorize + "redirect_uri": "https://client.example.com/other-callback", + }, + ) + assert response.status_code == 400 + error_response = response.json() + assert error_response["error"] == "invalid_request" + assert "redirect_uri did not match" in error_response["error_description"] + + @pytest.mark.anyio + async def test_token_code_verifier_mismatch( + self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], auth_code: dict[str, str] + ): + """Test token endpoint error - PKCE code verifier mismatch.""" + # Try to use the code with an incorrect code verifier + response = await test_client.post( + "/token", + data={ + "grant_type": "authorization_code", + "client_id": registered_client["client_id"], + "client_secret": registered_client["client_secret"], + "code": auth_code["code"], + # Different from the one used to create challenge + "code_verifier": "incorrect_code_verifier", + "redirect_uri": auth_code["redirect_uri"], + }, + ) + assert response.status_code == 400 + error_response = response.json() + assert error_response["error"] == "invalid_grant" + assert "incorrect code_verifier" in error_response["error_description"] + + @pytest.mark.anyio + async def test_token_invalid_refresh_token(self, test_client: httpx.AsyncClient, registered_client: dict[str, Any]): + """Test token endpoint error - refresh token does not exist.""" + # Try to use a non-existent refresh token + response = await test_client.post( + "/token", + data={ + "grant_type": "refresh_token", + "client_id": registered_client["client_id"], + "client_secret": registered_client["client_secret"], + "refresh_token": "non_existent_refresh_token", + }, + ) + assert response.status_code == 400 + error_response = response.json() + assert error_response["error"] == "invalid_grant" + assert "refresh token does not exist" in error_response["error_description"] + + @pytest.mark.anyio + async def test_token_expired_refresh_token( + self, + test_client: httpx.AsyncClient, + registered_client: dict[str, Any], + auth_code: dict[str, str], + pkce_challenge: dict[str, str], + ): + """Test token endpoint error - refresh token has expired.""" + # Step 1: First, let's create a token and refresh token at the current time + current_time = time.time() + + # Exchange authorization code for tokens normally + token_response = await test_client.post( + "/token", + data={ + "grant_type": "authorization_code", + "client_id": registered_client["client_id"], + "client_secret": registered_client["client_secret"], + "code": auth_code["code"], + "code_verifier": pkce_challenge["code_verifier"], + "redirect_uri": auth_code["redirect_uri"], + }, + ) + assert token_response.status_code == 200 + tokens = token_response.json() + refresh_token = tokens["refresh_token"] + + # Step 2: Time travel forward 4 hours (tokens expire in 1 hour by default) + # Mock the time.time() function to return a value 4 hours in the future + with unittest.mock.patch("time.time", return_value=current_time + 14400): # 4 hours = 14400 seconds + # Try to use the refresh token which should now be considered expired + response = await test_client.post( + "/token", + data={ + "grant_type": "refresh_token", + "client_id": registered_client["client_id"], + "client_secret": registered_client["client_secret"], + "refresh_token": refresh_token, + }, + ) + + # In the "future", the token should be considered expired + assert response.status_code == 400 + error_response = response.json() + assert error_response["error"] == "invalid_grant" + assert "refresh token has expired" in error_response["error_description"] + + @pytest.mark.anyio + async def test_token_invalid_scope( + self, + test_client: httpx.AsyncClient, + registered_client: dict[str, Any], + auth_code: dict[str, str], + pkce_challenge: dict[str, str], + ): + """Test token endpoint error - invalid scope in refresh token request.""" + # Exchange authorization code for tokens + token_response = await test_client.post( + "/token", + data={ + "grant_type": "authorization_code", + "client_id": registered_client["client_id"], + "client_secret": registered_client["client_secret"], + "code": auth_code["code"], + "code_verifier": pkce_challenge["code_verifier"], + "redirect_uri": auth_code["redirect_uri"], + }, + ) + assert token_response.status_code == 200 + + tokens = token_response.json() + refresh_token = tokens["refresh_token"] + + # Try to use refresh token with an invalid scope + response = await test_client.post( + "/token", + data={ + "grant_type": "refresh_token", + "client_id": registered_client["client_id"], + "client_secret": registered_client["client_secret"], + "refresh_token": refresh_token, + "scope": "read write invalid_scope", # Adding an invalid scope + }, + ) + assert response.status_code == 400 + error_response = response.json() + assert error_response["error"] == "invalid_scope" + assert "cannot request scope" in error_response["error_description"] + + @pytest.mark.anyio + async def test_client_registration(self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider): + """Test client registration.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Test Client", + "client_uri": "https://client.example.com", + } + + response = await test_client.post( + "/register", + json=client_metadata, + ) + assert response.status_code == 201, response.content + + client_info = response.json() + assert "client_id" in client_info + assert "client_secret" in client_info + assert client_info["client_name"] == "Test Client" + assert client_info["redirect_uris"] == ["https://client.example.com/callback"] + + # Verify that the client was registered + # assert await mock_oauth_provider.clients_store.get_client( + # client_info["client_id"] + # ) is not None + + @pytest.mark.anyio + async def test_client_registration_missing_required_fields(self, test_client: httpx.AsyncClient): + """Test client registration with missing required fields.""" + # Missing redirect_uris which is a required field + client_metadata = { + "client_name": "Test Client", + "client_uri": "https://client.example.com", + } + + response = await test_client.post( + "/register", + json=client_metadata, + ) + assert response.status_code == 400 + error_data = response.json() + assert "error" in error_data + assert error_data["error"] == "invalid_client_metadata" + assert error_data["error_description"] == "redirect_uris: Field required" + + @pytest.mark.anyio + async def test_client_registration_invalid_uri(self, test_client: httpx.AsyncClient): + """Test client registration with invalid URIs.""" + # Invalid redirect_uri format + client_metadata = { + "redirect_uris": ["not-a-valid-uri"], + "client_name": "Test Client", + } + + response = await test_client.post( + "/register", + json=client_metadata, + ) + assert response.status_code == 400 + error_data = response.json() + assert "error" in error_data + assert error_data["error"] == "invalid_client_metadata" + assert error_data["error_description"] == ( + "redirect_uris.0: Input should be a valid URL, relative URL without a base" + ) + + @pytest.mark.anyio + async def test_client_registration_empty_redirect_uris(self, test_client: httpx.AsyncClient): + """Test client registration with empty redirect_uris array.""" + redirect_uris: list[str] = [] + client_metadata = { + "redirect_uris": redirect_uris, # Empty array + "client_name": "Test Client", + } + + response = await test_client.post( + "/register", + json=client_metadata, + ) + assert response.status_code == 400 + error_data = response.json() + assert "error" in error_data + assert error_data["error"] == "invalid_client_metadata" + assert ( + error_data["error_description"] == "redirect_uris: List should have at least 1 item after validation, not 0" + ) + + @pytest.mark.anyio + async def test_authorize_form_post(self, test_client: httpx.AsyncClient, pkce_challenge: dict[str, str]): + """Test the authorization endpoint using POST with form-encoded data.""" + # Register a client + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Test Client", + "grant_types": ["authorization_code", "refresh_token"], + } + + response = await test_client.post( + "/register", + json=client_metadata, + ) + assert response.status_code == 201 + client_info = response.json() + + # Use POST with form-encoded data for authorization + response = await test_client.post( + "/authorize", + data={ + "response_type": "code", + "client_id": client_info["client_id"], + "redirect_uri": "https://client.example.com/callback", + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + "state": "test_form_state", + }, + ) + assert response.status_code == 302 + + # Extract the authorization code from the redirect URL + redirect_url = response.headers["location"] + parsed_url = urlparse(redirect_url) + query_params = parse_qs(parsed_url.query) + + assert "code" in query_params + assert query_params["state"][0] == "test_form_state" + + @pytest.mark.anyio + async def test_authorization_get( + self, + test_client: httpx.AsyncClient, + mock_oauth_provider: MockOAuthProvider, + pkce_challenge: dict[str, str], + ): + """Test the full authorization flow.""" + # 1. Register a client + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Test Client", + "grant_types": ["authorization_code", "refresh_token"], + } + + response = await test_client.post( + "/register", + json=client_metadata, + ) + assert response.status_code == 201 + client_info = response.json() + + # 2. Request authorization using GET with query params + response = await test_client.get( + "/authorize", + params={ + "response_type": "code", + "client_id": client_info["client_id"], + "redirect_uri": "https://client.example.com/callback", + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + "state": "test_state", + }, + ) + assert response.status_code == 302 + + # 3. Extract the authorization code from the redirect URL + redirect_url = response.headers["location"] + parsed_url = urlparse(redirect_url) + query_params = parse_qs(parsed_url.query) + + assert "code" in query_params + assert query_params["state"][0] == "test_state" + auth_code = query_params["code"][0] + + # 4. Exchange the authorization code for tokens + response = await test_client.post( + "/token", + data={ + "grant_type": "authorization_code", + "client_id": client_info["client_id"], + "client_secret": client_info["client_secret"], + "code": auth_code, + "code_verifier": pkce_challenge["code_verifier"], + "redirect_uri": "https://client.example.com/callback", + }, + ) + assert response.status_code == 200 + + token_response = response.json() + assert "access_token" in token_response + assert "token_type" in token_response + assert "refresh_token" in token_response + assert "expires_in" in token_response + assert token_response["token_type"] == "Bearer" + + # 5. Verify the access token + access_token = token_response["access_token"] + refresh_token = token_response["refresh_token"] + + # Create a test client with the token + auth_info = await mock_oauth_provider.load_access_token(access_token) + assert auth_info + assert auth_info.client_id == client_info["client_id"] + assert "read" in auth_info.scopes + assert "write" in auth_info.scopes + + # 6. Refresh the token + response = await test_client.post( + "/token", + data={ + "grant_type": "refresh_token", + "client_id": client_info["client_id"], + "client_secret": client_info["client_secret"], + "refresh_token": refresh_token, + "redirect_uri": "https://client.example.com/callback", + }, + ) + assert response.status_code == 200 + + new_token_response = response.json() + assert "access_token" in new_token_response + assert "refresh_token" in new_token_response + assert new_token_response["access_token"] != access_token + assert new_token_response["refresh_token"] != refresh_token + + # 7. Revoke the token + response = await test_client.post( + "/revoke", + data={ + "client_id": client_info["client_id"], + "client_secret": client_info["client_secret"], + "token": new_token_response["access_token"], + }, + ) + assert response.status_code == 200 + + # Verify that the token was revoked + assert await mock_oauth_provider.load_access_token(new_token_response["access_token"]) is None + + @pytest.mark.anyio + async def test_revoke_invalid_token(self, test_client: httpx.AsyncClient, registered_client: dict[str, Any]): + """Test revoking an invalid token.""" + response = await test_client.post( + "/revoke", + data={ + "client_id": registered_client["client_id"], + "client_secret": registered_client["client_secret"], + "token": "invalid_token", + }, + ) + # per RFC, this should return 200 even if the token is invalid + assert response.status_code == 200 + + @pytest.mark.anyio + async def test_revoke_with_malformed_token(self, test_client: httpx.AsyncClient, registered_client: dict[str, Any]): + response = await test_client.post( + "/revoke", + data={ + "client_id": registered_client["client_id"], + "client_secret": registered_client["client_secret"], + "token": 123, + "token_type_hint": "asdf", + }, + ) + assert response.status_code == 400 + error_response = response.json() + assert error_response["error"] == "invalid_request" + assert "token_type_hint" in error_response["error_description"] + + @pytest.mark.anyio + async def test_client_registration_disallowed_scopes(self, test_client: httpx.AsyncClient): + """Test client registration with scopes that are not allowed.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Test Client", + "scope": "read write profile admin", # 'admin' is not in valid_scopes + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 400 + error_data = response.json() + assert "error" in error_data + assert error_data["error"] == "invalid_client_metadata" + assert "scope" in error_data["error_description"] + assert "admin" in error_data["error_description"] + + @pytest.mark.anyio + async def test_client_registration_default_scopes( + self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider + ): + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Test Client", + # No scope specified + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 201 + client_info = response.json() + + # Verify client was registered successfully + assert client_info["scope"] == "read write" + + # Retrieve the client from the store to verify default scopes + registered_client = await mock_oauth_provider.get_client(client_info["client_id"]) + assert registered_client is not None + + # Check that default scopes were applied + assert registered_client.scope == "read write" + + @pytest.mark.anyio + async def test_client_registration_invalid_grant_type(self, test_client: httpx.AsyncClient): + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Test Client", + "grant_types": ["authorization_code"], + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 400 + error_data = response.json() + assert "error" in error_data + assert error_data["error"] == "invalid_client_metadata" + assert error_data["error_description"] == "grant_types must be authorization_code and refresh_token" + + @pytest.mark.anyio + async def test_client_registration_with_additional_grant_type(self, test_client: httpx.AsyncClient): + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Test Client", + "grant_types": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"], + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 201 + client_info = response.json() + + # Verify client was registered successfully + assert "client_id" in client_info + assert "client_secret" in client_info + assert client_info["client_name"] == "Test Client" + + @pytest.mark.anyio + async def test_client_registration_with_additional_response_types( + self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider + ): + """Test that registration accepts additional response_types values alongside 'code'.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Test Client", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code", "none"], # Keycloak-style response with additional value + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 201 + data = response.json() + + client = await mock_oauth_provider.get_client(data["client_id"]) + assert client is not None + assert "code" in client.response_types + + @pytest.mark.anyio + async def test_client_registration_response_types_without_code(self, test_client: httpx.AsyncClient): + """Test that registration rejects response_types that don't include 'code'.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Test Client", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["token", "none", "nonsense-string"], + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 400 + error_data = response.json() + assert "error" in error_data + assert error_data["error"] == "invalid_client_metadata" + assert "response_types must include 'code'" in error_data["error_description"] + + @pytest.mark.anyio + async def test_client_registration_default_response_types( + self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider + ): + """Test that registration uses default response_types of ['code'] when not specified.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Test Client", + "grant_types": ["authorization_code", "refresh_token"], + # response_types not specified, should default to ["code"] + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 201 + data = response.json() + + assert "response_types" in data + assert data["response_types"] == ["code"] + + @pytest.mark.anyio + async def test_client_secret_basic_authentication( + self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str] + ): + """Test that client_secret_basic authentication works correctly.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Basic Auth Client", + "token_endpoint_auth_method": "client_secret_basic", + "grant_types": ["authorization_code", "refresh_token"], + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 201 + client_info = response.json() + assert client_info["token_endpoint_auth_method"] == "client_secret_basic" + + auth_code = f"code_{int(time.time())}" + mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode( + code=auth_code, + client_id=client_info["client_id"], + code_challenge=pkce_challenge["code_challenge"], + redirect_uri=AnyUrl("https://client.example.com/callback"), + redirect_uri_provided_explicitly=True, + scopes=["read", "write"], + expires_at=time.time() + 600, + ) + + credentials = f"{client_info['client_id']}:{client_info['client_secret']}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + + response = await test_client.post( + "/token", + headers={"Authorization": f"Basic {encoded_credentials}"}, + data={ + "grant_type": "authorization_code", + "client_id": client_info["client_id"], + "code": auth_code, + "code_verifier": pkce_challenge["code_verifier"], + "redirect_uri": "https://client.example.com/callback", + }, + ) + assert response.status_code == 200 + token_response = response.json() + assert "access_token" in token_response + + @pytest.mark.anyio + async def test_wrong_auth_method_without_valid_credentials_fails( + self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str] + ): + """Test that using the wrong authentication method fails when credentials are missing.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Post Auth Client", + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["authorization_code", "refresh_token"], + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 201 + client_info = response.json() + assert client_info["token_endpoint_auth_method"] == "client_secret_post" + + auth_code = f"code_{int(time.time())}" + mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode( + code=auth_code, + client_id=client_info["client_id"], + code_challenge=pkce_challenge["code_challenge"], + redirect_uri=AnyUrl("https://client.example.com/callback"), + redirect_uri_provided_explicitly=True, + scopes=["read", "write"], + expires_at=time.time() + 600, + ) + + # Try to use Basic auth when client_secret_post is registered (without secret in body) + # This should fail because the secret is missing from the expected location + + credentials = f"{client_info['client_id']}:{client_info['client_secret']}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + + response = await test_client.post( + "/token", + headers={"Authorization": f"Basic {encoded_credentials}"}, + data={ + "grant_type": "authorization_code", + "client_id": client_info["client_id"], + # client_secret NOT in body where it should be + "code": auth_code, + "code_verifier": pkce_challenge["code_verifier"], + "redirect_uri": "https://client.example.com/callback", + }, + ) + assert response.status_code == 401 + error_response = response.json() + # RFC 6749: authentication failures return "invalid_client" + assert error_response["error"] == "invalid_client" + assert "Client secret is required" in error_response["error_description"] + + @pytest.mark.anyio + async def test_basic_auth_without_header_fails( + self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str] + ): + """Test that omitting Basic auth when client_secret_basic is registered fails.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Basic Auth Client", + "token_endpoint_auth_method": "client_secret_basic", + "grant_types": ["authorization_code", "refresh_token"], + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 201 + client_info = response.json() + assert client_info["token_endpoint_auth_method"] == "client_secret_basic" + + auth_code = f"code_{int(time.time())}" + mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode( + code=auth_code, + client_id=client_info["client_id"], + code_challenge=pkce_challenge["code_challenge"], + redirect_uri=AnyUrl("https://client.example.com/callback"), + redirect_uri_provided_explicitly=True, + scopes=["read", "write"], + expires_at=time.time() + 600, + ) + + response = await test_client.post( + "/token", + data={ + "grant_type": "authorization_code", + "client_id": client_info["client_id"], + "client_secret": client_info["client_secret"], # Secret in body (ignored) + "code": auth_code, + "code_verifier": pkce_challenge["code_verifier"], + "redirect_uri": "https://client.example.com/callback", + }, + ) + assert response.status_code == 401 + error_response = response.json() + # RFC 6749: authentication failures return "invalid_client" + assert error_response["error"] == "invalid_client" + assert "Missing or invalid Basic authentication" in error_response["error_description"] + + @pytest.mark.anyio + async def test_basic_auth_invalid_base64_fails( + self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str] + ): + """Test that invalid base64 in Basic auth header fails.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Basic Auth Client", + "token_endpoint_auth_method": "client_secret_basic", + "grant_types": ["authorization_code", "refresh_token"], + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 201 + client_info = response.json() + + auth_code = f"code_{int(time.time())}" + mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode( + code=auth_code, + client_id=client_info["client_id"], + code_challenge=pkce_challenge["code_challenge"], + redirect_uri=AnyUrl("https://client.example.com/callback"), + redirect_uri_provided_explicitly=True, + scopes=["read", "write"], + expires_at=time.time() + 600, + ) + + # Send invalid base64 + response = await test_client.post( + "/token", + headers={"Authorization": "Basic !!!invalid-base64!!!"}, + data={ + "grant_type": "authorization_code", + "client_id": client_info["client_id"], + "code": auth_code, + "code_verifier": pkce_challenge["code_verifier"], + "redirect_uri": "https://client.example.com/callback", + }, + ) + assert response.status_code == 401 + error_response = response.json() + # RFC 6749: authentication failures return "invalid_client" + assert error_response["error"] == "invalid_client" + assert "Invalid Basic authentication header" in error_response["error_description"] + + @pytest.mark.anyio + async def test_basic_auth_no_colon_fails( + self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str] + ): + """Test that Basic auth without colon separator fails.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Basic Auth Client", + "token_endpoint_auth_method": "client_secret_basic", + "grant_types": ["authorization_code", "refresh_token"], + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 201 + client_info = response.json() + + auth_code = f"code_{int(time.time())}" + mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode( + code=auth_code, + client_id=client_info["client_id"], + code_challenge=pkce_challenge["code_challenge"], + redirect_uri=AnyUrl("https://client.example.com/callback"), + redirect_uri_provided_explicitly=True, + scopes=["read", "write"], + expires_at=time.time() + 600, + ) + + # Send base64 without colon (invalid format) + import base64 + + invalid_creds = base64.b64encode(b"no-colon-here").decode() + response = await test_client.post( + "/token", + headers={"Authorization": f"Basic {invalid_creds}"}, + data={ + "grant_type": "authorization_code", + "client_id": client_info["client_id"], + "code": auth_code, + "code_verifier": pkce_challenge["code_verifier"], + "redirect_uri": "https://client.example.com/callback", + }, + ) + assert response.status_code == 401 + error_response = response.json() + # RFC 6749: authentication failures return "invalid_client" + assert error_response["error"] == "invalid_client" + assert "Invalid Basic authentication header" in error_response["error_description"] + + @pytest.mark.anyio + async def test_basic_auth_client_id_mismatch_fails( + self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str] + ): + """Test that client_id mismatch between body and Basic auth fails.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Basic Auth Client", + "token_endpoint_auth_method": "client_secret_basic", + "grant_types": ["authorization_code", "refresh_token"], + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 201 + client_info = response.json() + + auth_code = f"code_{int(time.time())}" + mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode( + code=auth_code, + client_id=client_info["client_id"], + code_challenge=pkce_challenge["code_challenge"], + redirect_uri=AnyUrl("https://client.example.com/callback"), + redirect_uri_provided_explicitly=True, + scopes=["read", "write"], + expires_at=time.time() + 600, + ) + + # Send different client_id in Basic auth header + import base64 + + wrong_creds = base64.b64encode(f"wrong-client-id:{client_info['client_secret']}".encode()).decode() + response = await test_client.post( + "/token", + headers={"Authorization": f"Basic {wrong_creds}"}, + data={ + "grant_type": "authorization_code", + "client_id": client_info["client_id"], # Correct client_id in body + "code": auth_code, + "code_verifier": pkce_challenge["code_verifier"], + "redirect_uri": "https://client.example.com/callback", + }, + ) + assert response.status_code == 401 + error_response = response.json() + # RFC 6749: authentication failures return "invalid_client" + assert error_response["error"] == "invalid_client" + assert "Client ID mismatch" in error_response["error_description"] + + @pytest.mark.anyio + async def test_none_auth_method_public_client( + self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str] + ): + """Test that 'none' authentication method works for public clients.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Public Client", + "token_endpoint_auth_method": "none", + "grant_types": ["authorization_code", "refresh_token"], + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 201 + client_info = response.json() + assert client_info["token_endpoint_auth_method"] == "none" + # Public clients should not have a client_secret + assert "client_secret" not in client_info or client_info.get("client_secret") is None + + auth_code = f"code_{int(time.time())}" + mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode( + code=auth_code, + client_id=client_info["client_id"], + code_challenge=pkce_challenge["code_challenge"], + redirect_uri=AnyUrl("https://client.example.com/callback"), + redirect_uri_provided_explicitly=True, + scopes=["read", "write"], + expires_at=time.time() + 600, + ) + + # Token request without any client secret + response = await test_client.post( + "/token", + data={ + "grant_type": "authorization_code", + "client_id": client_info["client_id"], + "code": auth_code, + "code_verifier": pkce_challenge["code_verifier"], + "redirect_uri": "https://client.example.com/callback", + }, + ) + assert response.status_code == 200 + token_response = response.json() + assert "access_token" in token_response + + +class TestAuthorizeEndpointErrors: + """Test error handling in the OAuth authorization endpoint.""" + + @pytest.mark.anyio + async def test_authorize_missing_client_id(self, test_client: httpx.AsyncClient, pkce_challenge: dict[str, str]): + """Test authorization endpoint with missing client_id. + + According to the OAuth2.0 spec, if client_id is missing, the server should + inform the resource owner and NOT redirect. + """ + response = await test_client.get( + "/authorize", + params={ + "response_type": "code", + # Missing client_id + "redirect_uri": "https://client.example.com/callback", + "state": "test_state", + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + }, + ) + + # Should NOT redirect, should show an error page + assert response.status_code == 400 + # The response should include an error message about missing client_id + assert "client_id" in response.text.lower() + + @pytest.mark.anyio + async def test_authorize_invalid_client_id(self, test_client: httpx.AsyncClient, pkce_challenge: dict[str, str]): + """Test authorization endpoint with invalid client_id. + + According to the OAuth2.0 spec, if client_id is invalid, the server should + inform the resource owner and NOT redirect. + """ + response = await test_client.get( + "/authorize", + params={ + "response_type": "code", + "client_id": "invalid_client_id_that_does_not_exist", + "redirect_uri": "https://client.example.com/callback", + "state": "test_state", + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + }, + ) + + # Should NOT redirect, should show an error page + assert response.status_code == 400 + # The response should include an error message about invalid client_id + assert "client" in response.text.lower() + + @pytest.mark.anyio + async def test_authorize_missing_redirect_uri( + self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str] + ): + """Test authorization endpoint with missing redirect_uri. + + If client has only one registered redirect_uri, it can be omitted. + """ + + response = await test_client.get( + "/authorize", + params={ + "response_type": "code", + "client_id": registered_client["client_id"], + # Missing redirect_uri + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + "state": "test_state", + }, + ) + + # Should redirect to the registered redirect_uri + assert response.status_code == 302, response.content + redirect_url = response.headers["location"] + assert redirect_url.startswith("https://client.example.com/callback") + + @pytest.mark.anyio + async def test_authorize_invalid_redirect_uri( + self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str] + ): + """Test authorization endpoint with invalid redirect_uri. + + According to the OAuth2.0 spec, if redirect_uri is invalid or doesn't match, + the server should inform the resource owner and NOT redirect. + """ + + response = await test_client.get( + "/authorize", + params={ + "response_type": "code", + "client_id": registered_client["client_id"], + # Non-matching URI + "redirect_uri": "https://attacker.example.com/callback", + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + "state": "test_state", + }, + ) + + # Should NOT redirect, should show an error page + assert response.status_code == 400, response.content + # The response should include an error message about redirect_uri mismatch + assert "redirect" in response.text.lower() + + @pytest.mark.anyio + @pytest.mark.parametrize( + "registered_client", + [ + { + "redirect_uris": [ + "https://client.example.com/callback", + "https://client.example.com/other-callback", + ] + } + ], + indirect=True, + ) + async def test_authorize_missing_redirect_uri_multiple_registered( + self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str] + ): + """Test endpoint with missing redirect_uri with multiple registered URIs. + + If client has multiple registered redirect_uris, redirect_uri must be provided. + """ + + response = await test_client.get( + "/authorize", + params={ + "response_type": "code", + "client_id": registered_client["client_id"], + # Missing redirect_uri + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + "state": "test_state", + }, + ) + + # Should NOT redirect, should return a 400 error + assert response.status_code == 400 + # The response should include an error message about missing redirect_uri + assert "redirect_uri" in response.text.lower() + + @pytest.mark.anyio + async def test_authorize_unsupported_response_type( + self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str] + ): + """Test authorization endpoint with unsupported response_type. + + According to the OAuth2.0 spec, for other errors like unsupported_response_type, + the server should redirect with error parameters. + """ + + response = await test_client.get( + "/authorize", + params={ + "response_type": "token", # Unsupported (we only support "code") + "client_id": registered_client["client_id"], + "redirect_uri": "https://client.example.com/callback", + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + "state": "test_state", + }, + ) + + # Should redirect with error parameters + assert response.status_code == 302 + redirect_url = response.headers["location"] + parsed_url = urlparse(redirect_url) + query_params = parse_qs(parsed_url.query) + + assert "error" in query_params + assert query_params["error"][0] == "unsupported_response_type" + # State should be preserved + assert "state" in query_params + assert query_params["state"][0] == "test_state" + + @pytest.mark.anyio + async def test_authorize_missing_response_type( + self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str] + ): + """Test authorization endpoint with missing response_type. + + Missing required parameter should result in invalid_request error. + """ + + response = await test_client.get( + "/authorize", + params={ + # Missing response_type + "client_id": registered_client["client_id"], + "redirect_uri": "https://client.example.com/callback", + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + "state": "test_state", + }, + ) + + # Should redirect with error parameters + assert response.status_code == 302 + redirect_url = response.headers["location"] + parsed_url = urlparse(redirect_url) + query_params = parse_qs(parsed_url.query) + + assert "error" in query_params + assert query_params["error"][0] == "invalid_request" + # State should be preserved + assert "state" in query_params + assert query_params["state"][0] == "test_state" + + @pytest.mark.anyio + async def test_authorize_missing_pkce_challenge( + self, test_client: httpx.AsyncClient, registered_client: dict[str, Any] + ): + """Test authorization endpoint with missing PKCE code_challenge. + + Missing PKCE parameters should result in invalid_request error. + """ + response = await test_client.get( + "/authorize", + params={ + "response_type": "code", + "client_id": registered_client["client_id"], + # Missing code_challenge + "state": "test_state", + # using default URL + }, + ) + + # Should redirect with error parameters + assert response.status_code == 302 + redirect_url = response.headers["location"] + parsed_url = urlparse(redirect_url) + query_params = parse_qs(parsed_url.query) + + assert "error" in query_params + assert query_params["error"][0] == "invalid_request" + # State should be preserved + assert "state" in query_params + assert query_params["state"][0] == "test_state" + + @pytest.mark.anyio + async def test_authorize_invalid_scope( + self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str] + ): + """Test authorization endpoint with invalid scope. + + Invalid scope should redirect with invalid_scope error. + """ + + response = await test_client.get( + "/authorize", + params={ + "response_type": "code", + "client_id": registered_client["client_id"], + "redirect_uri": "https://client.example.com/callback", + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + "scope": "invalid_scope_that_does_not_exist", + "state": "test_state", + }, + ) + + # Should redirect with error parameters + assert response.status_code == 302 + redirect_url = response.headers["location"] + parsed_url = urlparse(redirect_url) + query_params = parse_qs(parsed_url.query) + + assert "error" in query_params + assert query_params["error"][0] == "invalid_scope" + # State should be preserved + assert "state" in query_params + assert query_params["state"][0] == "test_state" diff --git a/tests/server/fastmcp/prompts/test_base.py b/tests/server/fastmcp/prompts/test_base.py index c4af044a63..488bd5002c 100644 --- a/tests/server/fastmcp/prompts/test_base.py +++ b/tests/server/fastmcp/prompts/test_base.py @@ -1,13 +1,9 @@ +from typing import Any + import pytest from pydantic import FileUrl -from mcp.server.fastmcp.prompts.base import ( - AssistantMessage, - Message, - Prompt, - TextContent, - UserMessage, -) +from mcp.server.fastmcp.prompts.base import AssistantMessage, Message, Prompt, TextContent, UserMessage from mcp.types import EmbeddedResource, TextResourceContents @@ -18,9 +14,7 @@ def fn() -> str: return "Hello, world!" prompt = Prompt.from_function(fn) - assert await prompt.render() == [ - UserMessage(content=TextContent(type="text", text="Hello, world!")) - ] + assert await prompt.render() == [UserMessage(content=TextContent(type="text", text="Hello, world!"))] @pytest.mark.anyio async def test_async_fn(self): @@ -28,9 +22,7 @@ async def fn() -> str: return "Hello, world!" prompt = Prompt.from_function(fn) - assert await prompt.render() == [ - UserMessage(content=TextContent(type="text", text="Hello, world!")) - ] + assert await prompt.render() == [UserMessage(content=TextContent(type="text", text="Hello, world!"))] @pytest.mark.anyio async def test_fn_with_args(self): @@ -39,16 +31,12 @@ async def fn(name: str, age: int = 30) -> str: prompt = Prompt.from_function(fn) assert await prompt.render(arguments={"name": "World"}) == [ - UserMessage( - content=TextContent( - type="text", text="Hello, World! You're 30 years old." - ) - ) + UserMessage(content=TextContent(type="text", text="Hello, World! You're 30 years old.")) ] @pytest.mark.anyio async def test_fn_with_invalid_kwargs(self): - async def fn(name: str, age: int = 30) -> str: + async def fn(name: str, age: int = 30) -> str: # pragma: no cover return f"Hello, {name}! You're {age} years old." prompt = Prompt.from_function(fn) @@ -61,25 +49,19 @@ async def fn() -> UserMessage: return UserMessage(content="Hello, world!") prompt = Prompt.from_function(fn) - assert await prompt.render() == [ - UserMessage(content=TextContent(type="text", text="Hello, world!")) - ] + assert await prompt.render() == [UserMessage(content=TextContent(type="text", text="Hello, world!"))] @pytest.mark.anyio async def test_fn_returns_assistant_message(self): async def fn() -> AssistantMessage: - return AssistantMessage( - content=TextContent(type="text", text="Hello, world!") - ) + return AssistantMessage(content=TextContent(type="text", text="Hello, world!")) prompt = Prompt.from_function(fn) - assert await prompt.render() == [ - AssistantMessage(content=TextContent(type="text", text="Hello, world!")) - ] + assert await prompt.render() == [AssistantMessage(content=TextContent(type="text", text="Hello, world!"))] @pytest.mark.anyio async def test_fn_returns_multiple_messages(self): - expected = [ + expected: list[Message] = [ UserMessage("Hello, world!"), AssistantMessage("How can I help you today?"), UserMessage("I'm looking for a restaurant in the center of town."), @@ -156,9 +138,7 @@ async def fn() -> list[Message]: prompt = Prompt.from_function(fn) assert await prompt.render() == [ - UserMessage( - content=TextContent(type="text", text="Please analyze this file:") - ), + UserMessage(content=TextContent(type="text", text="Please analyze this file:")), UserMessage( content=EmbeddedResource( type="resource", @@ -169,16 +149,14 @@ async def fn() -> list[Message]: ), ) ), - AssistantMessage( - content=TextContent(type="text", text="I'll help analyze that file.") - ), + AssistantMessage(content=TextContent(type="text", text="I'll help analyze that file.")), ] @pytest.mark.anyio async def test_fn_returns_dict_with_resource(self): """Test returning a dict with resource content.""" - async def fn() -> dict: + async def fn() -> dict[str, Any]: return { "role": "user", "content": { diff --git a/tests/server/fastmcp/prompts/test_manager.py b/tests/server/fastmcp/prompts/test_manager.py index c64a4a564f..950ffddd1a 100644 --- a/tests/server/fastmcp/prompts/test_manager.py +++ b/tests/server/fastmcp/prompts/test_manager.py @@ -8,7 +8,7 @@ class TestPromptManager: def test_add_prompt(self): """Test adding a prompt to the manager.""" - def fn() -> str: + def fn() -> str: # pragma: no cover return "Hello, world!" manager = PromptManager() @@ -17,10 +17,10 @@ def fn() -> str: assert added == prompt assert manager.get_prompt("fn") == prompt - def test_add_duplicate_prompt(self, caplog): + def test_add_duplicate_prompt(self, caplog: pytest.LogCaptureFixture): """Test adding the same prompt twice.""" - def fn() -> str: + def fn() -> str: # pragma: no cover return "Hello, world!" manager = PromptManager() @@ -30,10 +30,10 @@ def fn() -> str: assert first == second assert "Prompt already exists" in caplog.text - def test_disable_warn_on_duplicate_prompts(self, caplog): + def test_disable_warn_on_duplicate_prompts(self, caplog: pytest.LogCaptureFixture): """Test disabling warning on duplicate prompts.""" - def fn() -> str: + def fn() -> str: # pragma: no cover return "Hello, world!" manager = PromptManager(warn_on_duplicate_prompts=False) @@ -46,10 +46,10 @@ def fn() -> str: def test_list_prompts(self): """Test listing all prompts.""" - def fn1() -> str: + def fn1() -> str: # pragma: no cover return "Hello, world!" - def fn2() -> str: + def fn2() -> str: # pragma: no cover return "Goodbye, world!" manager = PromptManager() @@ -72,9 +72,7 @@ def fn() -> str: prompt = Prompt.from_function(fn) manager.add_prompt(prompt) messages = await manager.render_prompt("fn") - assert messages == [ - UserMessage(content=TextContent(type="text", text="Hello, world!")) - ] + assert messages == [UserMessage(content=TextContent(type="text", text="Hello, world!"))] @pytest.mark.anyio async def test_render_prompt_with_args(self): @@ -87,9 +85,7 @@ def fn(name: str) -> str: prompt = Prompt.from_function(fn) manager.add_prompt(prompt) messages = await manager.render_prompt("fn", arguments={"name": "World"}) - assert messages == [ - UserMessage(content=TextContent(type="text", text="Hello, World!")) - ] + assert messages == [UserMessage(content=TextContent(type="text", text="Hello, World!"))] @pytest.mark.anyio async def test_render_unknown_prompt(self): @@ -102,7 +98,7 @@ async def test_render_unknown_prompt(self): async def test_render_prompt_with_missing_args(self): """Test rendering a prompt with missing required arguments.""" - def fn(name: str) -> str: + def fn(name: str) -> str: # pragma: no cover return f"Hello, {name}!" manager = PromptManager() diff --git a/tests/server/fastmcp/resources/test_file_resources.py b/tests/server/fastmcp/resources/test_file_resources.py index 36cbca32c9..c82cf85c5a 100644 --- a/tests/server/fastmcp/resources/test_file_resources.py +++ b/tests/server/fastmcp/resources/test_file_resources.py @@ -19,9 +19,9 @@ def temp_file(): f.write(content) path = Path(f.name).resolve() yield path - try: + try: # pragma: no cover path.unlink() - except FileNotFoundError: + except FileNotFoundError: # pragma: no cover pass # File was already deleted by the test @@ -100,11 +100,9 @@ async def test_missing_file_error(self, temp_file: Path): with pytest.raises(ValueError, match="Error reading file"): await resource.read() - @pytest.mark.skipif( - os.name == "nt", reason="File permissions behave differently on Windows" - ) + @pytest.mark.skipif(os.name == "nt", reason="File permissions behave differently on Windows") @pytest.mark.anyio - async def test_permission_error(self, temp_file: Path): + async def test_permission_error(self, temp_file: Path): # pragma: no cover """Test reading a file without permissions.""" temp_file.chmod(0o000) # Remove all permissions try: diff --git a/tests/server/fastmcp/resources/test_function_resources.py b/tests/server/fastmcp/resources/test_function_resources.py index 5bfc72bf68..fccada4750 100644 --- a/tests/server/fastmcp/resources/test_function_resources.py +++ b/tests/server/fastmcp/resources/test_function_resources.py @@ -10,7 +10,7 @@ class TestFunctionResource: def test_function_resource_creation(self): """Test creating a FunctionResource.""" - def my_func() -> str: + def my_func() -> str: # pragma: no cover return "test content" resource = FunctionResource( @@ -60,7 +60,7 @@ def get_data() -> bytes: async def test_json_conversion(self): """Test automatic JSON conversion of non-string results.""" - def get_data() -> dict: + def get_data() -> dict[str, str]: return {"key": "value"} resource = FunctionResource( @@ -100,7 +100,7 @@ class MyModel(BaseModel): fn=lambda: MyModel(name="test"), ) content = await resource.read() - assert content == '{"name": "test"}' + assert content == '{\n "name": "test"\n}' @pytest.mark.anyio async def test_custom_type_conversion(self): @@ -136,3 +136,22 @@ async def get_data() -> str: content = await resource.read() assert content == "Hello, world!" assert resource.mime_type == "text/plain" + + @pytest.mark.anyio + async def test_from_function(self): + """Test creating a FunctionResource from a function.""" + + async def get_data() -> str: # pragma: no cover + """get_data returns a string""" + return "Hello, world!" + + resource = FunctionResource.from_function( + fn=get_data, + uri="function://test", + name="test", + ) + + assert resource.description == "get_data returns a string" + assert resource.mime_type == "text/plain" + assert resource.name == "test" + assert resource.uri == AnyUrl("function://test") diff --git a/tests/server/fastmcp/resources/test_resource_manager.py b/tests/server/fastmcp/resources/test_resource_manager.py index 4423e53159..a0c06be86c 100644 --- a/tests/server/fastmcp/resources/test_resource_manager.py +++ b/tests/server/fastmcp/resources/test_resource_manager.py @@ -4,12 +4,7 @@ import pytest from pydantic import AnyUrl, FileUrl -from mcp.server.fastmcp.resources import ( - FileResource, - FunctionResource, - ResourceManager, - ResourceTemplate, -) +from mcp.server.fastmcp.resources import FileResource, FunctionResource, ResourceManager, ResourceTemplate @pytest.fixture @@ -23,9 +18,9 @@ def temp_file(): f.write(content) path = Path(f.name).resolve() yield path - try: + try: # pragma: no cover path.unlink() - except FileNotFoundError: + except FileNotFoundError: # pragma: no cover pass # File was already deleted by the test @@ -57,7 +52,7 @@ def test_add_duplicate_resource(self, temp_file: Path): assert first == second assert manager.list_resources() == [resource] - def test_warn_on_duplicate_resources(self, temp_file: Path, caplog): + def test_warn_on_duplicate_resources(self, temp_file: Path, caplog: pytest.LogCaptureFixture): """Test warning on duplicate resources.""" manager = ResourceManager() resource = FileResource( @@ -69,7 +64,7 @@ def test_warn_on_duplicate_resources(self, temp_file: Path, caplog): manager.add_resource(resource) assert "Resource already exists" in caplog.text - def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog): + def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog: pytest.LogCaptureFixture): """Test disabling warning on duplicate resources.""" manager = ResourceManager(warn_on_duplicate_resources=False) resource = FileResource( diff --git a/tests/server/fastmcp/resources/test_resource_template.py b/tests/server/fastmcp/resources/test_resource_template.py index 09bc600d01..c910f8fa85 100644 --- a/tests/server/fastmcp/resources/test_resource_template.py +++ b/tests/server/fastmcp/resources/test_resource_template.py @@ -1,9 +1,12 @@ import json +from typing import Any import pytest from pydantic import BaseModel +from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp.resources import FunctionResource, ResourceTemplate +from mcp.types import Annotations class TestResourceTemplate: @@ -12,7 +15,7 @@ class TestResourceTemplate: def test_template_creation(self): """Test creating a template from a function.""" - def my_func(key: str, value: int) -> dict: + def my_func(key: str, value: int) -> dict[str, Any]: # pragma: no cover return {"key": key, "value": value} template = ResourceTemplate.from_function( @@ -23,13 +26,12 @@ def my_func(key: str, value: int) -> dict: assert template.uri_template == "test://{key}/{value}" assert template.name == "test" assert template.mime_type == "text/plain" # default - test_input = {"key": "test", "value": 42} - assert template.fn(**test_input) == my_func(**test_input) + assert template.fn(key="test", value=42) == my_func(key="test", value=42) def test_template_matches(self): """Test matching URIs against a template.""" - def my_func(key: str, value: int) -> dict: + def my_func(key: str, value: int) -> dict[str, Any]: # pragma: no cover return {"key": key, "value": value} template = ResourceTemplate.from_function( @@ -50,7 +52,7 @@ def my_func(key: str, value: int) -> dict: async def test_create_resource(self): """Test creating a resource from a template.""" - def my_func(key: str, value: int) -> dict: + def my_func(key: str, value: int) -> dict[str, Any]: return {"key": key, "value": value} template = ResourceTemplate.from_function( @@ -185,4 +187,74 @@ def get_data(value: str) -> CustomData: assert isinstance(resource, FunctionResource) content = await resource.read() - assert content == "hello" + assert content == '"hello"' + + +class TestResourceTemplateAnnotations: + """Test annotations on resource templates.""" + + def test_template_with_annotations(self): + """Test creating a template with annotations.""" + + def get_user_data(user_id: str) -> str: # pragma: no cover + return f"User {user_id}" + + annotations = Annotations(priority=0.9) + + template = ResourceTemplate.from_function( + fn=get_user_data, uri_template="resource://users/{user_id}", annotations=annotations + ) + + assert template.annotations is not None + assert template.annotations.priority == 0.9 + + def test_template_without_annotations(self): + """Test that annotations are optional for templates.""" + + def get_user_data(user_id: str) -> str: # pragma: no cover + return f"User {user_id}" + + template = ResourceTemplate.from_function(fn=get_user_data, uri_template="resource://users/{user_id}") + + assert template.annotations is None + + @pytest.mark.anyio + async def test_template_annotations_in_fastmcp(self): + """Test template annotations via FastMCP decorator.""" + + mcp = FastMCP() + + @mcp.resource("resource://dynamic/{id}", annotations=Annotations(audience=["user"], priority=0.7)) + def get_dynamic(id: str) -> str: # pragma: no cover + """A dynamic annotated resource.""" + return f"Data for {id}" + + templates = await mcp.list_resource_templates() + assert len(templates) == 1 + assert templates[0].annotations is not None + assert templates[0].annotations.audience == ["user"] + assert templates[0].annotations.priority == 0.7 + + @pytest.mark.anyio + async def test_template_created_resources_inherit_annotations(self): + """Test that resources created from templates inherit annotations.""" + + def get_item(item_id: str) -> str: # pragma: no cover + return f"Item {item_id}" + + annotations = Annotations(priority=0.6) + + template = ResourceTemplate.from_function( + fn=get_item, uri_template="resource://items/{item_id}", annotations=annotations + ) + + # Create a resource from the template + resource = await template.create_resource("resource://items/123", {"item_id": "123"}) + + # The resource should inherit the template's annotations + assert resource.annotations is not None + assert resource.annotations.priority == 0.6 + + # Verify the resource works correctly + content = await resource.read() + assert content == "Item 123" diff --git a/tests/server/fastmcp/resources/test_resources.py b/tests/server/fastmcp/resources/test_resources.py index 08b3e65e12..32fc23b174 100644 --- a/tests/server/fastmcp/resources/test_resources.py +++ b/tests/server/fastmcp/resources/test_resources.py @@ -1,7 +1,9 @@ import pytest from pydantic import AnyUrl +from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp.resources import FunctionResource, Resource +from mcp.types import Annotations class TestResourceValidation: @@ -10,7 +12,7 @@ class TestResourceValidation: def test_resource_uri_validation(self): """Test URI validation.""" - def dummy_func() -> str: + def dummy_func() -> str: # pragma: no cover return "data" # Valid URI @@ -40,7 +42,7 @@ def dummy_func() -> str: def test_resource_name_from_uri(self): """Test name is extracted from URI if not provided.""" - def dummy_func() -> str: + def dummy_func() -> str: # pragma: no cover return "data" resource = FunctionResource( @@ -52,7 +54,7 @@ def dummy_func() -> str: def test_resource_name_validation(self): """Test name validation.""" - def dummy_func() -> str: + def dummy_func() -> str: # pragma: no cover return "data" # Must provide either name or URI @@ -72,7 +74,7 @@ def dummy_func() -> str: def test_resource_mime_type(self): """Test mime type handling.""" - def dummy_func() -> str: + def dummy_func() -> str: # pragma: no cover return "data" # Default mime type @@ -99,3 +101,95 @@ class ConcreteResource(Resource): with pytest.raises(TypeError, match="abstract method"): ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore + + +class TestResourceAnnotations: + """Test annotations on resources.""" + + def test_resource_with_annotations(self): + """Test creating a resource with annotations.""" + + def get_data() -> str: # pragma: no cover + return "data" + + annotations = Annotations(audience=["user"], priority=0.8) + + resource = FunctionResource.from_function(fn=get_data, uri="resource://test", annotations=annotations) + + assert resource.annotations is not None + assert resource.annotations.audience == ["user"] + assert resource.annotations.priority == 0.8 + + def test_resource_without_annotations(self): + """Test that annotations are optional.""" + + def get_data() -> str: # pragma: no cover + return "data" + + resource = FunctionResource.from_function(fn=get_data, uri="resource://test") + + assert resource.annotations is None + + @pytest.mark.anyio + async def test_resource_annotations_in_fastmcp(self): + """Test resource annotations via FastMCP decorator.""" + + mcp = FastMCP() + + @mcp.resource("resource://annotated", annotations=Annotations(audience=["assistant"], priority=0.5)) + def get_annotated() -> str: # pragma: no cover + """An annotated resource.""" + return "annotated data" + + resources = await mcp.list_resources() + assert len(resources) == 1 + assert resources[0].annotations is not None + assert resources[0].annotations.audience == ["assistant"] + assert resources[0].annotations.priority == 0.5 + + @pytest.mark.anyio + async def test_resource_annotations_with_both_audiences(self): + """Test resource with both user and assistant audience.""" + + mcp = FastMCP() + + @mcp.resource("resource://both", annotations=Annotations(audience=["user", "assistant"], priority=1.0)) + def get_both() -> str: # pragma: no cover + return "for everyone" + + resources = await mcp.list_resources() + assert resources[0].annotations is not None + assert resources[0].annotations.audience == ["user", "assistant"] + assert resources[0].annotations.priority == 1.0 + + +class TestAnnotationsValidation: + """Test validation of annotation values.""" + + def test_priority_validation(self): + """Test that priority is validated to be between 0.0 and 1.0.""" + + # Valid priorities + Annotations(priority=0.0) + Annotations(priority=0.5) + Annotations(priority=1.0) + + # Invalid priorities should raise validation error + with pytest.raises(Exception): # Pydantic validation error + Annotations(priority=-0.1) + + with pytest.raises(Exception): + Annotations(priority=1.1) + + def test_audience_validation(self): + """Test that audience only accepts valid roles.""" + + # Valid audiences + Annotations(audience=["user"]) + Annotations(audience=["assistant"]) + Annotations(audience=["user", "assistant"]) + Annotations(audience=[]) + + # Invalid roles should raise validation error + with pytest.raises(Exception): # Pydantic validation error + Annotations(audience=["invalid_role"]) # type: ignore diff --git a/tests/server/fastmcp/servers/test_file_server.py b/tests/server/fastmcp/servers/test_file_server.py index c1f51cabe3..b8c9ad3d6a 100644 --- a/tests/server/fastmcp/servers/test_file_server.py +++ b/tests/server/fastmcp/servers/test_file_server.py @@ -7,7 +7,7 @@ @pytest.fixture() -def test_dir(tmp_path_factory) -> Path: +def test_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: """Create a temporary directory with test files.""" tmp = tmp_path_factory.mktemp("test_files") @@ -44,17 +44,17 @@ def read_example_py() -> str: @mcp.resource("file://test_dir/readme.md") def read_readme_md() -> str: """Read the readme.md file""" - try: + try: # pragma: no cover return (test_dir / "readme.md").read_text() - except FileNotFoundError: + except FileNotFoundError: # pragma: no cover return "File not found" @mcp.resource("file://test_dir/config.json") def read_config_json() -> str: """Read the config.json file""" - try: + try: # pragma: no cover return (test_dir / "config.json").read_text() - except FileNotFoundError: + except FileNotFoundError: # pragma: no cover return "File not found" return mcp @@ -65,7 +65,7 @@ def tools(mcp: FastMCP, test_dir: Path) -> FastMCP: @mcp.tool() def delete_file(path: str) -> bool: # ensure path is in test_dir - if Path(path).resolve().parent != test_dir: + if Path(path).resolve().parent != test_dir: # pragma: no cover raise ValueError(f"Path must be in test_dir: {path}") Path(path).unlink() return True @@ -114,17 +114,13 @@ async def test_read_resource_file(mcp: FastMCP): @pytest.mark.anyio async def test_delete_file(mcp: FastMCP, test_dir: Path): - await mcp.call_tool( - "delete_file", arguments={"path": str(test_dir / "example.py")} - ) + await mcp.call_tool("delete_file", arguments={"path": str(test_dir / "example.py")}) assert not (test_dir / "example.py").exists() @pytest.mark.anyio async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path): - await mcp.call_tool( - "delete_file", arguments={"path": str(test_dir / "example.py")} - ) + await mcp.call_tool("delete_file", arguments={"path": str(test_dir / "example.py")}) res_iter = await mcp.read_resource("file://test_dir/example.py") res_list = list(res_iter) assert len(res_list) == 1 diff --git a/tests/server/fastmcp/test_elicitation.py b/tests/server/fastmcp/test_elicitation.py new file mode 100644 index 0000000000..597b291785 --- /dev/null +++ b/tests/server/fastmcp/test_elicitation.py @@ -0,0 +1,405 @@ +""" +Test the elicitation feature using stdio transport. +""" + +from typing import Any + +import pytest +from pydantic import BaseModel, Field + +from mcp import types +from mcp.client.session import ClientSession, ElicitationFnT +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +from mcp.shared.context import RequestContext +from mcp.shared.memory import create_connected_server_and_client_session +from mcp.types import ElicitRequestParams, ElicitResult, TextContent + + +# Shared schema for basic tests +class AnswerSchema(BaseModel): + answer: str = Field(description="The user's answer to the question") + + +def create_ask_user_tool(mcp: FastMCP): + """Create a standard ask_user tool that handles all elicitation responses.""" + + @mcp.tool(description="A tool that uses elicitation") + async def ask_user(prompt: str, ctx: Context[ServerSession, None]) -> str: + result = await ctx.elicit(message=f"Tool wants to ask: {prompt}", schema=AnswerSchema) + + if result.action == "accept" and result.data: + return f"User answered: {result.data.answer}" + elif result.action == "decline": + return "User declined to answer" + else: # pragma: no cover + return "User cancelled" + + return ask_user + + +async def call_tool_and_assert( + mcp: FastMCP, + elicitation_callback: ElicitationFnT, + tool_name: str, + args: dict[str, Any], + expected_text: str | None = None, + text_contains: list[str] | None = None, +): + """Helper to create session, call tool, and assert result.""" + async with create_connected_server_and_client_session( + mcp._mcp_server, elicitation_callback=elicitation_callback + ) as client_session: + await client_session.initialize() + + result = await client_session.call_tool(tool_name, args) + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + + if expected_text is not None: + assert result.content[0].text == expected_text + elif text_contains is not None: # pragma: no branch + for substring in text_contains: + assert substring in result.content[0].text + + return result + + +@pytest.mark.anyio +async def test_stdio_elicitation(): + """Test the elicitation feature using stdio transport.""" + mcp = FastMCP(name="StdioElicitationServer") + create_ask_user_tool(mcp) + + # Create a custom handler for elicitation requests + async def elicitation_callback( + context: RequestContext[ClientSession, None], params: ElicitRequestParams + ): # pragma: no cover + if params.message == "Tool wants to ask: What is your name?": + return ElicitResult(action="accept", content={"answer": "Test User"}) + else: + raise ValueError(f"Unexpected elicitation message: {params.message}") + + await call_tool_and_assert( + mcp, elicitation_callback, "ask_user", {"prompt": "What is your name?"}, "User answered: Test User" + ) + + +@pytest.mark.anyio +async def test_stdio_elicitation_decline(): + """Test elicitation with user declining.""" + mcp = FastMCP(name="StdioElicitationDeclineServer") + create_ask_user_tool(mcp) + + async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + return ElicitResult(action="decline") + + await call_tool_and_assert( + mcp, elicitation_callback, "ask_user", {"prompt": "What is your name?"}, "User declined to answer" + ) + + +@pytest.mark.anyio +async def test_elicitation_schema_validation(): + """Test that elicitation schemas must only contain primitive types.""" + mcp = FastMCP(name="ValidationTestServer") + + def create_validation_tool(name: str, schema_class: type[BaseModel]): + @mcp.tool(name=name, description=f"Tool testing {name}") + async def tool(ctx: Context[ServerSession, None]) -> str: # pragma: no cover + try: + await ctx.elicit(message="This should fail validation", schema=schema_class) + return "Should not reach here" + except TypeError as e: + return f"Validation failed as expected: {str(e)}" + + return tool + + # Test cases for invalid schemas + class InvalidListSchema(BaseModel): + numbers: list[int] = Field(description="List of numbers") + + class NestedModel(BaseModel): + value: str + + class InvalidNestedSchema(BaseModel): + nested: NestedModel = Field(description="Nested model") + + create_validation_tool("invalid_list", InvalidListSchema) + create_validation_tool("nested_model", InvalidNestedSchema) + + # Dummy callback (won't be called due to validation failure) + async def elicitation_callback( + context: RequestContext[ClientSession, None], params: ElicitRequestParams + ): # pragma: no cover + return ElicitResult(action="accept", content={}) + + async with create_connected_server_and_client_session( + mcp._mcp_server, elicitation_callback=elicitation_callback + ) as client_session: + await client_session.initialize() + + # Test both invalid schemas + for tool_name, field_name in [("invalid_list", "numbers"), ("nested_model", "nested")]: + result = await client_session.call_tool(tool_name, {}) + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert "Validation failed as expected" in result.content[0].text + assert field_name in result.content[0].text + + +@pytest.mark.anyio +async def test_elicitation_with_optional_fields(): + """Test that Optional fields work correctly in elicitation schemas.""" + mcp = FastMCP(name="OptionalFieldServer") + + class OptionalSchema(BaseModel): + required_name: str = Field(description="Your name (required)") + optional_age: int | None = Field(default=None, description="Your age (optional)") + optional_email: str | None = Field(default=None, description="Your email (optional)") + subscribe: bool | None = Field(default=False, description="Subscribe to newsletter?") + + @mcp.tool(description="Tool with optional fields") + async def optional_tool(ctx: Context[ServerSession, None]) -> str: + result = await ctx.elicit(message="Please provide your information", schema=OptionalSchema) + + if result.action == "accept" and result.data: + info = [f"Name: {result.data.required_name}"] + if result.data.optional_age is not None: + info.append(f"Age: {result.data.optional_age}") + if result.data.optional_email is not None: + info.append(f"Email: {result.data.optional_email}") + info.append(f"Subscribe: {result.data.subscribe}") + return ", ".join(info) + else: # pragma: no cover + return f"User {result.action}" + + # Test cases with different field combinations + test_cases: list[tuple[dict[str, Any], str]] = [ + ( + # All fields provided + {"required_name": "John Doe", "optional_age": 30, "optional_email": "john@example.com", "subscribe": True}, + "Name: John Doe, Age: 30, Email: john@example.com, Subscribe: True", + ), + ( + # Only required fields + {"required_name": "Jane Smith"}, + "Name: Jane Smith, Subscribe: False", + ), + ] + + for content, expected in test_cases: + + async def callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + return ElicitResult(action="accept", content=content) + + await call_tool_and_assert(mcp, callback, "optional_tool", {}, expected) + + # Test invalid optional field + class InvalidOptionalSchema(BaseModel): + name: str = Field(description="Name") + optional_list: list[int] | None = Field(default=None, description="Invalid optional list") + + @mcp.tool(description="Tool with invalid optional field") + async def invalid_optional_tool(ctx: Context[ServerSession, None]) -> str: # pragma: no cover + try: + await ctx.elicit(message="This should fail", schema=InvalidOptionalSchema) + return "Should not reach here" + except TypeError as e: + return f"Validation failed: {str(e)}" + + async def elicitation_callback( + context: RequestContext[ClientSession, None], params: ElicitRequestParams + ): # pragma: no cover + return ElicitResult(action="accept", content={}) + + await call_tool_and_assert( + mcp, + elicitation_callback, + "invalid_optional_tool", + {}, + text_contains=["Validation failed:", "optional_list"], + ) + + # Test valid list[str] for multi-select enum + class ValidMultiSelectSchema(BaseModel): + name: str = Field(description="Name") + tags: list[str] = Field(description="Tags") + + @mcp.tool(description="Tool with valid list[str] field") + async def valid_multiselect_tool(ctx: Context[ServerSession, None]) -> str: + result = await ctx.elicit(message="Please provide tags", schema=ValidMultiSelectSchema) + if result.action == "accept" and result.data: + return f"Name: {result.data.name}, Tags: {', '.join(result.data.tags)}" + return f"User {result.action}" # pragma: no cover + + async def multiselect_callback(context: RequestContext[ClientSession, Any], params: ElicitRequestParams): + if "Please provide tags" in params.message: + return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]}) + return ElicitResult(action="decline") # pragma: no cover + + await call_tool_and_assert(mcp, multiselect_callback, "valid_multiselect_tool", {}, "Name: Test, Tags: tag1, tag2") + + # Test Optional[list[str]] for optional multi-select enum + class OptionalMultiSelectSchema(BaseModel): + name: str = Field(description="Name") + tags: list[str] | None = Field(default=None, description="Optional tags") + + @mcp.tool(description="Tool with optional list[str] field") + async def optional_multiselect_tool(ctx: Context[ServerSession, None]) -> str: + result = await ctx.elicit(message="Please provide optional tags", schema=OptionalMultiSelectSchema) + if result.action == "accept" and result.data: + tags_str = ", ".join(result.data.tags) if result.data.tags else "none" + return f"Name: {result.data.name}, Tags: {tags_str}" + return f"User {result.action}" # pragma: no cover + + async def optional_multiselect_callback(context: RequestContext[ClientSession, Any], params: ElicitRequestParams): + if "Please provide optional tags" in params.message: + return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]}) + return ElicitResult(action="decline") # pragma: no cover + + await call_tool_and_assert( + mcp, optional_multiselect_callback, "optional_multiselect_tool", {}, "Name: Test, Tags: tag1, tag2" + ) + + +@pytest.mark.anyio +async def test_elicitation_with_default_values(): + """Test that default values work correctly in elicitation schemas and are included in JSON.""" + mcp = FastMCP(name="DefaultValuesServer") + + class DefaultsSchema(BaseModel): + name: str = Field(default="Guest", description="User name") + age: int = Field(default=18, description="User age") + subscribe: bool = Field(default=True, description="Subscribe to newsletter") + email: str = Field(description="Email address (required)") + + @mcp.tool(description="Tool with default values") + async def defaults_tool(ctx: Context[ServerSession, None]) -> str: + result = await ctx.elicit(message="Please provide your information", schema=DefaultsSchema) + + if result.action == "accept" and result.data: + return ( + f"Name: {result.data.name}, Age: {result.data.age}, " + f"Subscribe: {result.data.subscribe}, Email: {result.data.email}" + ) + else: # pragma: no cover + return f"User {result.action}" + + # First verify that defaults are present in the JSON schema sent to clients + async def callback_schema_verify(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + # Verify the schema includes defaults + assert isinstance(params, types.ElicitRequestFormParams), "Expected form mode elicitation" + schema = params.requestedSchema + props = schema["properties"] + + assert props["name"]["default"] == "Guest" + assert props["age"]["default"] == 18 + assert props["subscribe"]["default"] is True + assert "default" not in props["email"] # Required field has no default + + return ElicitResult(action="accept", content={"email": "test@example.com"}) + + await call_tool_and_assert( + mcp, + callback_schema_verify, + "defaults_tool", + {}, + "Name: Guest, Age: 18, Subscribe: True, Email: test@example.com", + ) + + # Test overriding defaults + async def callback_override(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + return ElicitResult( + action="accept", content={"email": "john@example.com", "name": "John", "age": 25, "subscribe": False} + ) + + await call_tool_and_assert( + mcp, callback_override, "defaults_tool", {}, "Name: John, Age: 25, Subscribe: False, Email: john@example.com" + ) + + +@pytest.mark.anyio +async def test_elicitation_with_enum_titles(): + """Test elicitation with enum schemas using oneOf/anyOf for titles.""" + mcp = FastMCP(name="ColorPreferencesApp") + + # Test single-select with titles using oneOf + class FavoriteColorSchema(BaseModel): + user_name: str = Field(description="Your name") + favorite_color: str = Field( + description="Select your favorite color", + json_schema_extra={ + "oneOf": [ + {"const": "red", "title": "Red"}, + {"const": "green", "title": "Green"}, + {"const": "blue", "title": "Blue"}, + {"const": "yellow", "title": "Yellow"}, + ] + }, + ) + + @mcp.tool(description="Single color selection") + async def select_favorite_color(ctx: Context[ServerSession, None]) -> str: + result = await ctx.elicit(message="Select your favorite color", schema=FavoriteColorSchema) + if result.action == "accept" and result.data: + return f"User: {result.data.user_name}, Favorite: {result.data.favorite_color}" + return f"User {result.action}" # pragma: no cover + + # Test multi-select with titles using anyOf + class FavoriteColorsSchema(BaseModel): + user_name: str = Field(description="Your name") + favorite_colors: list[str] = Field( + description="Select your favorite colors", + json_schema_extra={ + "items": { + "anyOf": [ + {"const": "red", "title": "Red"}, + {"const": "green", "title": "Green"}, + {"const": "blue", "title": "Blue"}, + {"const": "yellow", "title": "Yellow"}, + ] + } + }, + ) + + @mcp.tool(description="Multiple color selection") + async def select_favorite_colors(ctx: Context[ServerSession, None]) -> str: + result = await ctx.elicit(message="Select your favorite colors", schema=FavoriteColorsSchema) + if result.action == "accept" and result.data: + return f"User: {result.data.user_name}, Colors: {', '.join(result.data.favorite_colors)}" + return f"User {result.action}" # pragma: no cover + + # Test legacy enumNames format + class LegacyColorSchema(BaseModel): + user_name: str = Field(description="Your name") + color: str = Field( + description="Select a color", + json_schema_extra={"enum": ["red", "green", "blue"], "enumNames": ["Red", "Green", "Blue"]}, + ) + + @mcp.tool(description="Legacy enum format") + async def select_color_legacy(ctx: Context[ServerSession, None]) -> str: + result = await ctx.elicit(message="Select a color (legacy format)", schema=LegacyColorSchema) + if result.action == "accept" and result.data: + return f"User: {result.data.user_name}, Color: {result.data.color}" + return f"User {result.action}" # pragma: no cover + + async def enum_callback(context: RequestContext[ClientSession, Any], params: ElicitRequestParams): + if "colors" in params.message and "legacy" not in params.message: + return ElicitResult(action="accept", content={"user_name": "Bob", "favorite_colors": ["red", "green"]}) + elif "color" in params.message: + if "legacy" in params.message: + return ElicitResult(action="accept", content={"user_name": "Charlie", "color": "green"}) + else: + return ElicitResult(action="accept", content={"user_name": "Alice", "favorite_color": "blue"}) + return ElicitResult(action="decline") # pragma: no cover + + # Test single-select with titles + await call_tool_and_assert(mcp, enum_callback, "select_favorite_color", {}, "User: Alice, Favorite: blue") + + # Test multi-select with titles + await call_tool_and_assert(mcp, enum_callback, "select_favorite_colors", {}, "User: Bob, Colors: red, green") + + # Test legacy enumNames format + await call_tool_and_assert(mcp, enum_callback, "select_color_legacy", {}, "User: Charlie, Color: green") diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/fastmcp/test_func_metadata.py index b1828ffe9e..61e524290e 100644 --- a/tests/server/fastmcp/test_func_metadata.py +++ b/tests/server/fastmcp/test_func_metadata.py @@ -1,10 +1,19 @@ -from typing import Annotated +# NOTE: Those were added because we actually want to test wrong type annotations. +# pyright: reportUnknownParameterType=false +# pyright: reportMissingParameterType=false +# pyright: reportUnknownArgumentType=false +# pyright: reportUnknownLambdaType=false +from collections.abc import Callable +from dataclasses import dataclass +from typing import Annotated, Any, Final, TypedDict import annotated_types import pytest +from dirty_equals import IsPartialDict from pydantic import BaseModel, Field from mcp.server.fastmcp.utilities.func_metadata import func_metadata +from mcp.types import CallToolResult class SomeInputModelA(BaseModel): @@ -28,9 +37,7 @@ def complex_arguments_fn( # list[str] | str is an interesting case because if it comes in as JSON like # "[\"a\", \"b\"]" then it will be naively parsed as a string. list_str_or_str: list[str] | str, - an_int_annotated_with_field: Annotated[ - int, Field(description="An int with a field") - ], + an_int_annotated_with_field: Annotated[int, Field(description="An int with a field")], an_int_annotated_with_field_and_others: Annotated[ int, str, # Should be ignored, really @@ -42,9 +49,7 @@ def complex_arguments_fn( "123", 456, ], - field_with_default_via_field_annotation_before_nondefault_arg: Annotated[ - int, Field(1) - ], + field_with_default_via_field_annotation_before_nondefault_arg: Annotated[int, Field(1)], unannotated, my_model_a: SomeInputModelA, my_model_a_forward_ref: "SomeInputModelA", @@ -60,7 +65,7 @@ def complex_arguments_fn( an_int_with_equals_field: int = Field(1, ge=0), int_annotated_with_default: Annotated[int, Field(description="hey")] = 5, ) -> str: - _ = ( + _: Any = ( an_int, must_be_none, must_be_none_dumb_annotation, @@ -156,7 +161,7 @@ def test_str_vs_list_str(): We want to make sure it's kept as a python string. """ - def func_with_str_types(str_or_list: str | list[str]): + def func_with_str_types(str_or_list: str | list[str]): # pragma: no cover return str_or_list meta = func_metadata(func_with_str_types) @@ -179,9 +184,7 @@ def func_with_str_types(str_or_list: str | list[str]): def test_skip_names(): """Test that skipped parameters are not included in the model""" - def func_with_many_params( - keep_this: int, skip_this: str, also_keep: float, also_skip: bool - ): + def func_with_many_params(keep_this: int, skip_this: str, also_keep: float, also_skip: bool): # pragma: no cover return keep_this, skip_this, also_keep, also_skip # Skip some parameters @@ -199,10 +202,52 @@ def func_with_many_params( assert model.also_keep == 2.5 # type: ignore +def test_structured_output_dict_str_types(): + """Test that dict[str, T] types are handled without wrapping.""" + + # Test dict[str, Any] + def func_dict_any() -> dict[str, Any]: # pragma: no cover + return {"a": 1, "b": "hello", "c": [1, 2, 3]} + + meta = func_metadata(func_dict_any) + + assert meta.output_schema == IsPartialDict(type="object", title="func_dict_anyDictOutput") + + # Test dict[str, str] + def func_dict_str() -> dict[str, str]: # pragma: no cover + return {"name": "John", "city": "NYC"} + + meta = func_metadata(func_dict_str) + assert meta.output_schema == { + "type": "object", + "additionalProperties": {"type": "string"}, + "title": "func_dict_strDictOutput", + } + + # Test dict[str, list[int]] + def func_dict_list() -> dict[str, list[int]]: # pragma: no cover + return {"nums": [1, 2, 3], "more": [4, 5, 6]} + + meta = func_metadata(func_dict_list) + assert meta.output_schema == { + "type": "object", + "additionalProperties": {"type": "array", "items": {"type": "integer"}}, + "title": "func_dict_listDictOutput", + } + + # Test dict[int, str] - should be wrapped since key is not str + def func_dict_int_key() -> dict[int, str]: # pragma: no cover + return {1: "a", 2: "b"} + + meta = func_metadata(func_dict_int_key) + assert meta.output_schema is not None + assert "result" in meta.output_schema["properties"] + + @pytest.mark.anyio async def test_lambda_function(): """Test lambda function schema and validation""" - fn = lambda x, y=5: x # noqa: E731 + fn: Callable[[str, int], str] = lambda x, y=5: x # noqa: E731 meta = func_metadata(lambda x, y=5: x) # Test schema @@ -267,8 +312,8 @@ def test_complex_function_json_schema(): normalized_schema = actual_schema.copy() # Normalize the my_model_a_with_default field to handle both pydantic formats - if "allOf" in actual_schema["properties"]["my_model_a_with_default"]: - normalized_schema["properties"]["my_model_a_with_default"] = { + if "allOf" in actual_schema["properties"]["my_model_a_with_default"]: # pragma: no cover + normalized_schema["properties"]["my_model_a_with_default"] = { # pragma: no cover "$ref": "#/$defs/SomeInputModelA", "default": {}, } @@ -407,10 +452,753 @@ def test_str_vs_int(): while numbers are parsed correctly. """ - def func_with_str_and_int(a: str, b: int): + def func_with_str_and_int(a: str, b: int): # pragma: no cover return a meta = func_metadata(func_with_str_and_int) result = meta.pre_parse_json({"a": "123", "b": 123}) assert result["a"] == "123" assert result["b"] == 123 + + +def test_str_annotation_preserves_json_string(): + """ + Regression test for PR #1113: Ensure that when a parameter is annotated as str, + valid JSON strings are NOT parsed into Python objects. + + This test would fail before the fix (JSON string would be parsed to dict) + and passes after the fix (JSON string remains as string). + """ + + def process_json_config(config: str, enabled: bool = True) -> str: # pragma: no cover + """Function that expects a JSON string as a string parameter.""" + # In real use, this function might validate or transform the JSON string + # before parsing it, or pass it to another service as-is + return f"Processing config: {config}" + + meta = func_metadata(process_json_config) + + # Test case 1: JSON object as string + json_obj_str = '{"database": "postgres", "port": 5432}' + result = meta.pre_parse_json({"config": json_obj_str, "enabled": True}) + + # The config parameter should remain as a string, NOT be parsed to a dict + assert isinstance(result["config"], str) + assert result["config"] == json_obj_str + + # Test case 2: JSON array as string + json_array_str = '["item1", "item2", "item3"]' + result = meta.pre_parse_json({"config": json_array_str}) + + # Should remain as string + assert isinstance(result["config"], str) + assert result["config"] == json_array_str + + # Test case 3: JSON string value (double-encoded) + json_string_str = '"This is a JSON string"' + result = meta.pre_parse_json({"config": json_string_str}) + + # Should remain as the original string with quotes + assert isinstance(result["config"], str) + assert result["config"] == json_string_str + + # Test case 4: Complex nested JSON as string + complex_json_str = '{"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}], "count": 2}' + result = meta.pre_parse_json({"config": complex_json_str}) + + # Should remain as string + assert isinstance(result["config"], str) + assert result["config"] == complex_json_str + + +@pytest.mark.anyio +async def test_str_annotation_runtime_validation(): + """ + Regression test for PR #1113: Test runtime validation with string parameters + containing valid JSON to ensure they are passed as strings, not parsed objects. + """ + + def handle_json_payload(payload: str, strict_mode: bool = False) -> str: # pragma: no cover + """Function that processes a JSON payload as a string.""" + # This function expects to receive the raw JSON string + # It might parse it later after validation or logging + assert isinstance(payload, str), f"Expected str, got {type(payload)}" + return f"Handled payload of length {len(payload)}" + + meta = func_metadata(handle_json_payload) + + # Test with a JSON object string + json_payload = '{"action": "create", "resource": "user", "data": {"name": "Test User"}}' + + result = await meta.call_fn_with_arg_validation( + handle_json_payload, + fn_is_async=False, + arguments_to_validate={"payload": json_payload, "strict_mode": True}, + arguments_to_pass_directly=None, + ) + + # The function should have received the string and returned successfully + assert result == f"Handled payload of length {len(json_payload)}" + + # Test with JSON array string + json_array_payload = '["task1", "task2", "task3"]' + + result = await meta.call_fn_with_arg_validation( + handle_json_payload, + fn_is_async=False, + arguments_to_validate={"payload": json_array_payload}, + arguments_to_pass_directly=None, + ) + + assert result == f"Handled payload of length {len(json_array_payload)}" + + +# Tests for structured output functionality + + +def test_structured_output_requires_return_annotation(): + """Test that structured_output=True requires a return annotation""" + from mcp.server.fastmcp.exceptions import InvalidSignature + + def func_no_annotation(): # pragma: no cover + return "hello" + + def func_none_annotation() -> None: # pragma: no cover + return None + + with pytest.raises(InvalidSignature) as exc_info: + func_metadata(func_no_annotation, structured_output=True) + assert "return annotation required" in str(exc_info.value) + + # None annotation should work + meta = func_metadata(func_none_annotation) + assert meta.output_schema == { + "type": "object", + "properties": {"result": {"title": "Result", "type": "null"}}, + "required": ["result"], + "title": "func_none_annotationOutput", + } + + +def test_structured_output_basemodel(): + """Test structured output with BaseModel return types""" + + class PersonModel(BaseModel): + name: str + age: int + email: str | None = None + + def func_returning_person() -> PersonModel: # pragma: no cover + return PersonModel(name="Alice", age=30) + + meta = func_metadata(func_returning_person) + assert meta.output_schema == { + "type": "object", + "properties": { + "name": {"title": "Name", "type": "string"}, + "age": {"title": "Age", "type": "integer"}, + "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None, "title": "Email"}, + }, + "required": ["name", "age"], + "title": "PersonModel", + } + + +def test_structured_output_primitives(): + """Test structured output with primitive return types""" + + def func_str() -> str: # pragma: no cover + return "hello" + + def func_int() -> int: # pragma: no cover + return 42 + + def func_float() -> float: # pragma: no cover + return 3.14 + + def func_bool() -> bool: # pragma: no cover + return True + + def func_bytes() -> bytes: # pragma: no cover + return b"data" + + # Test string + meta = func_metadata(func_str) + assert meta.output_schema == { + "type": "object", + "properties": {"result": {"title": "Result", "type": "string"}}, + "required": ["result"], + "title": "func_strOutput", + } + + # Test int + meta = func_metadata(func_int) + assert meta.output_schema == { + "type": "object", + "properties": {"result": {"title": "Result", "type": "integer"}}, + "required": ["result"], + "title": "func_intOutput", + } + + # Test float + meta = func_metadata(func_float) + assert meta.output_schema == { + "type": "object", + "properties": {"result": {"title": "Result", "type": "number"}}, + "required": ["result"], + "title": "func_floatOutput", + } + + # Test bool + meta = func_metadata(func_bool) + assert meta.output_schema == { + "type": "object", + "properties": {"result": {"title": "Result", "type": "boolean"}}, + "required": ["result"], + "title": "func_boolOutput", + } + + # Test bytes + meta = func_metadata(func_bytes) + assert meta.output_schema == { + "type": "object", + "properties": {"result": {"title": "Result", "type": "string", "format": "binary"}}, + "required": ["result"], + "title": "func_bytesOutput", + } + + +def test_structured_output_generic_types(): + """Test structured output with generic types (list, dict, Union, etc.)""" + + def func_list_str() -> list[str]: # pragma: no cover + return ["a", "b", "c"] + + def func_dict_str_int() -> dict[str, int]: # pragma: no cover + return {"a": 1, "b": 2} + + def func_union() -> str | int: # pragma: no cover + return "hello" + + def func_optional() -> str | None: # pragma: no cover + return None + + # Test list + meta = func_metadata(func_list_str) + assert meta.output_schema == { + "type": "object", + "properties": {"result": {"title": "Result", "type": "array", "items": {"type": "string"}}}, + "required": ["result"], + "title": "func_list_strOutput", + } + + # Test dict[str, int] - should NOT be wrapped + meta = func_metadata(func_dict_str_int) + assert meta.output_schema == { + "type": "object", + "additionalProperties": {"type": "integer"}, + "title": "func_dict_str_intDictOutput", + } + + # Test Union + meta = func_metadata(func_union) + assert meta.output_schema == { + "type": "object", + "properties": {"result": {"title": "Result", "anyOf": [{"type": "string"}, {"type": "integer"}]}}, + "required": ["result"], + "title": "func_unionOutput", + } + + # Test Optional + meta = func_metadata(func_optional) + assert meta.output_schema == { + "type": "object", + "properties": {"result": {"title": "Result", "anyOf": [{"type": "string"}, {"type": "null"}]}}, + "required": ["result"], + "title": "func_optionalOutput", + } + + +def test_structured_output_dataclass(): + """Test structured output with dataclass return types""" + + @dataclass + class PersonDataClass: + name: str + age: int + email: str | None = None + tags: list[str] | None = None + + def func_returning_dataclass() -> PersonDataClass: # pragma: no cover + return PersonDataClass(name="Bob", age=25) + + meta = func_metadata(func_returning_dataclass) + assert meta.output_schema == { + "type": "object", + "properties": { + "name": {"title": "Name", "type": "string"}, + "age": {"title": "Age", "type": "integer"}, + "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None, "title": "Email"}, + "tags": { + "anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], + "default": None, + "title": "Tags", + }, + }, + "required": ["name", "age"], + "title": "PersonDataClass", + } + + +def test_structured_output_typeddict(): + """Test structured output with TypedDict return types""" + + class PersonTypedDictOptional(TypedDict, total=False): + name: str + age: int + + def func_returning_typeddict_optional() -> PersonTypedDictOptional: # pragma: no cover + return {"name": "Dave"} # Only returning one field to test partial dict + + meta = func_metadata(func_returning_typeddict_optional) + assert meta.output_schema == { + "type": "object", + "properties": { + "name": {"title": "Name", "type": "string", "default": None}, + "age": {"title": "Age", "type": "integer", "default": None}, + }, + "title": "PersonTypedDictOptional", + } + + # Test with total=True (all required) + class PersonTypedDictRequired(TypedDict): + name: str + age: int + email: str | None + + def func_returning_typeddict_required() -> PersonTypedDictRequired: # pragma: no cover + return {"name": "Eve", "age": 40, "email": None} # Testing None value + + meta = func_metadata(func_returning_typeddict_required) + assert meta.output_schema == { + "type": "object", + "properties": { + "name": {"title": "Name", "type": "string"}, + "age": {"title": "Age", "type": "integer"}, + "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Email"}, + }, + "required": ["name", "age", "email"], + "title": "PersonTypedDictRequired", + } + + +def test_structured_output_ordinary_class(): + """Test structured output with ordinary annotated classes""" + + class PersonClass: + name: str + age: int + email: str | None + + def __init__(self, name: str, age: int, email: str | None = None): # pragma: no cover + self.name = name + self.age = age + self.email = email + + def func_returning_class() -> PersonClass: # pragma: no cover + return PersonClass("Helen", 55) + + meta = func_metadata(func_returning_class) + assert meta.output_schema == { + "type": "object", + "properties": { + "name": {"title": "Name", "type": "string"}, + "age": {"title": "Age", "type": "integer"}, + "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Email"}, + }, + "required": ["name", "age", "email"], + "title": "PersonClass", + } + + +def test_unstructured_output_unannotated_class(): + # Test with class that has no annotations + class UnannotatedClass: + def __init__(self, x, y): # pragma: no cover + self.x = x + self.y = y + + def func_returning_unannotated() -> UnannotatedClass: # pragma: no cover + return UnannotatedClass(1, 2) + + meta = func_metadata(func_returning_unannotated) + assert meta.output_schema is None + + +def test_tool_call_result_is_unstructured_and_not_converted(): + def func_returning_call_tool_result() -> CallToolResult: # pragma: no cover + return CallToolResult(content=[]) + + meta = func_metadata(func_returning_call_tool_result) + + assert meta.output_schema is None + assert isinstance(meta.convert_result(func_returning_call_tool_result()), CallToolResult) + + +def test_tool_call_result_annotated_is_structured_and_converted(): + class PersonClass(BaseModel): + name: str + + def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, PersonClass]: # pragma: no cover + return CallToolResult(content=[], structuredContent={"name": "Brandon"}) + + meta = func_metadata(func_returning_annotated_tool_call_result) + + assert meta.output_schema == { + "type": "object", + "properties": { + "name": {"title": "Name", "type": "string"}, + }, + "required": ["name"], + "title": "PersonClass", + } + assert isinstance(meta.convert_result(func_returning_annotated_tool_call_result()), CallToolResult) + + +def test_tool_call_result_annotated_is_structured_and_invalid(): + class PersonClass(BaseModel): + name: str + + def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, PersonClass]: # pragma: no cover + return CallToolResult(content=[], structuredContent={"person": "Brandon"}) + + meta = func_metadata(func_returning_annotated_tool_call_result) + + with pytest.raises(ValueError): + meta.convert_result(func_returning_annotated_tool_call_result()) + + +def test_tool_call_result_in_optional_is_rejected(): + """Test that Optional[CallToolResult] raises InvalidSignature""" + + from mcp.server.fastmcp.exceptions import InvalidSignature + + def func_optional_call_tool_result() -> CallToolResult | None: # pragma: no cover + return CallToolResult(content=[]) + + with pytest.raises(InvalidSignature) as exc_info: + func_metadata(func_optional_call_tool_result) + + assert "Union or Optional" in str(exc_info.value) + assert "CallToolResult" in str(exc_info.value) + + +def test_tool_call_result_in_union_is_rejected(): + """Test that Union[str, CallToolResult] raises InvalidSignature""" + + from mcp.server.fastmcp.exceptions import InvalidSignature + + def func_union_call_tool_result() -> str | CallToolResult: # pragma: no cover + return CallToolResult(content=[]) + + with pytest.raises(InvalidSignature) as exc_info: + func_metadata(func_union_call_tool_result) + + assert "Union or Optional" in str(exc_info.value) + assert "CallToolResult" in str(exc_info.value) + + +def test_tool_call_result_in_pipe_union_is_rejected(): + """Test that str | CallToolResult raises InvalidSignature""" + from mcp.server.fastmcp.exceptions import InvalidSignature + + def func_pipe_union_call_tool_result() -> str | CallToolResult: # pragma: no cover + return CallToolResult(content=[]) + + with pytest.raises(InvalidSignature) as exc_info: + func_metadata(func_pipe_union_call_tool_result) + + assert "Union or Optional" in str(exc_info.value) + assert "CallToolResult" in str(exc_info.value) + + +def test_structured_output_with_field_descriptions(): + """Test that Field descriptions are preserved in structured output""" + + class ModelWithDescriptions(BaseModel): + name: Annotated[str, Field(description="The person's full name")] + age: Annotated[int, Field(description="Age in years", ge=0, le=150)] + + def func_with_descriptions() -> ModelWithDescriptions: # pragma: no cover + return ModelWithDescriptions(name="Ian", age=60) + + meta = func_metadata(func_with_descriptions) + assert meta.output_schema == { + "type": "object", + "properties": { + "name": {"title": "Name", "type": "string", "description": "The person's full name"}, + "age": {"title": "Age", "type": "integer", "description": "Age in years", "minimum": 0, "maximum": 150}, + }, + "required": ["name", "age"], + "title": "ModelWithDescriptions", + } + + +def test_structured_output_nested_models(): + """Test structured output with nested models""" + + class Address(BaseModel): + street: str + city: str + zipcode: str + + class PersonWithAddress(BaseModel): + name: str + address: Address + + def func_nested() -> PersonWithAddress: # pragma: no cover + return PersonWithAddress(name="Jack", address=Address(street="123 Main St", city="Anytown", zipcode="12345")) + + meta = func_metadata(func_nested) + assert meta.output_schema == { + "type": "object", + "$defs": { + "Address": { + "type": "object", + "properties": { + "street": {"title": "Street", "type": "string"}, + "city": {"title": "City", "type": "string"}, + "zipcode": {"title": "Zipcode", "type": "string"}, + }, + "required": ["street", "city", "zipcode"], + "title": "Address", + } + }, + "properties": { + "name": {"title": "Name", "type": "string"}, + "address": {"$ref": "#/$defs/Address"}, + }, + "required": ["name", "address"], + "title": "PersonWithAddress", + } + + +def test_structured_output_unserializable_type_error(): + """Test error when structured_output=True is used with unserializable types""" + from typing import NamedTuple + + from mcp.server.fastmcp.exceptions import InvalidSignature + + # Test with a class that has non-serializable default values + class ConfigWithCallable: + name: str + # Callable defaults are not JSON serializable and will trigger Pydantic warnings + callback: Callable[[Any], Any] = lambda x: x * 2 + + def func_returning_config_with_callable() -> ConfigWithCallable: # pragma: no cover + return ConfigWithCallable() + + # Should work without structured_output=True (returns None for output_schema) + meta = func_metadata(func_returning_config_with_callable) + assert meta.output_schema is None + + # Should raise error with structured_output=True + with pytest.raises(InvalidSignature) as exc_info: + func_metadata(func_returning_config_with_callable, structured_output=True) + assert "is not serializable for structured output" in str(exc_info.value) + assert "ConfigWithCallable" in str(exc_info.value) + + # Also test with NamedTuple for good measure + class Point(NamedTuple): + x: int + y: int + + def func_returning_namedtuple() -> Point: # pragma: no cover + return Point(1, 2) + + # Should work without structured_output=True (returns None for output_schema) + meta = func_metadata(func_returning_namedtuple) + assert meta.output_schema is None + + # Should raise error with structured_output=True + with pytest.raises(InvalidSignature) as exc_info: + func_metadata(func_returning_namedtuple, structured_output=True) + assert "is not serializable for structured output" in str(exc_info.value) + assert "Point" in str(exc_info.value) + + +def test_structured_output_aliases(): + """Test that field aliases are consistent between schema and output""" + + class ModelWithAliases(BaseModel): + field_first: str | None = Field(default=None, alias="first", description="The first field.") + field_second: str | None = Field(default=None, alias="second", description="The second field.") + + def func_with_aliases() -> ModelWithAliases: # pragma: no cover + # When aliases are defined, we must use the aliased names to set values + return ModelWithAliases(**{"first": "hello", "second": "world"}) + + meta = func_metadata(func_with_aliases) + + # Check that schema uses aliases + assert meta.output_schema is not None + assert "first" in meta.output_schema["properties"] + assert "second" in meta.output_schema["properties"] + assert "field_first" not in meta.output_schema["properties"] + assert "field_second" not in meta.output_schema["properties"] + + # Check that the actual output uses aliases too + result = ModelWithAliases(**{"first": "hello", "second": "world"}) + _, structured_content = meta.convert_result(result) + + # The structured content should use aliases to match the schema + assert "first" in structured_content + assert "second" in structured_content + assert "field_first" not in structured_content + assert "field_second" not in structured_content + assert structured_content["first"] == "hello" + assert structured_content["second"] == "world" + + # Also test the case where we have a model with defaults to ensure aliases work in all cases + result_with_defaults = ModelWithAliases() # Uses default None values + _, structured_content_defaults = meta.convert_result(result_with_defaults) + + # Even with defaults, should use aliases in output + assert "first" in structured_content_defaults + assert "second" in structured_content_defaults + assert "field_first" not in structured_content_defaults + assert "field_second" not in structured_content_defaults + assert structured_content_defaults["first"] is None + assert structured_content_defaults["second"] is None + + +def test_basemodel_reserved_names(): + """Test that functions with parameters named after BaseModel methods work correctly""" + + def func_with_reserved_names( # pragma: no cover + model_dump: str, + model_validate: int, + dict: list[str], + json: dict[str, Any], + validate: bool, + copy: float, + normal_param: str, + ) -> str: + return f"{model_dump}, {model_validate}, {dict}, {json}, {validate}, {copy}, {normal_param}" + + meta = func_metadata(func_with_reserved_names) + + # Check that the schema has all the original parameter names (using aliases) + schema = meta.arg_model.model_json_schema(by_alias=True) + assert "model_dump" in schema["properties"] + assert "model_validate" in schema["properties"] + assert "dict" in schema["properties"] + assert "json" in schema["properties"] + assert "validate" in schema["properties"] + assert "copy" in schema["properties"] + assert "normal_param" in schema["properties"] + + +@pytest.mark.anyio +async def test_basemodel_reserved_names_validation(): + """Test that validation and calling works with reserved parameter names""" + + def func_with_reserved_names( # pragma: no cover + model_dump: str, + model_validate: int, + dict: list[str], + json: dict[str, Any], + validate: bool, + normal_param: str, + ) -> str: + return f"{model_dump}|{model_validate}|{len(dict)}|{json}|{validate}|{normal_param}" + + meta = func_metadata(func_with_reserved_names) + + # Test validation with reserved names + result = await meta.call_fn_with_arg_validation( + func_with_reserved_names, + fn_is_async=False, + arguments_to_validate={ + "model_dump": "test_dump", + "model_validate": 42, + "dict": ["a", "b", "c"], + "json": {"key": "value"}, + "validate": True, + "normal_param": "normal", + }, + arguments_to_pass_directly=None, + ) + + assert result == "test_dump|42|3|{'key': 'value'}|True|normal" + + # Test that the model can still call its own methods + model_instance = meta.arg_model.model_validate( + { + "model_dump": "dump_value", + "model_validate": 123, + "dict": ["x", "y"], + "json": {"foo": "bar"}, + "validate": False, + "normal_param": "test", + } + ) + + # The model should still have its methods accessible + assert hasattr(model_instance, "model_dump") + assert callable(model_instance.model_dump) + + # model_dump_one_level should return the original parameter names + dumped = model_instance.model_dump_one_level() + assert dumped["model_dump"] == "dump_value" + assert dumped["model_validate"] == 123 + assert dumped["dict"] == ["x", "y"] + assert dumped["json"] == {"foo": "bar"} + assert dumped["validate"] is False + assert dumped["normal_param"] == "test" + + +def test_basemodel_reserved_names_with_json_preparsing(): + """Test that pre_parse_json works correctly with reserved parameter names""" + + def func_with_reserved_json( # pragma: no cover + json: dict[str, Any], + model_dump: list[int], + normal: str, + ) -> str: + return "ok" + + meta = func_metadata(func_with_reserved_json) + + # Test pre-parsing with reserved names + result = meta.pre_parse_json( + { + "json": '{"nested": "data"}', # JSON string that should be parsed + "model_dump": "[1, 2, 3]", # JSON string that should be parsed + "normal": "plain string", # Should remain as string + } + ) + + assert result["json"] == {"nested": "data"} + assert result["model_dump"] == [1, 2, 3] + assert result["normal"] == "plain string" + + +def test_disallowed_type_qualifier(): + from mcp.server.fastmcp.exceptions import InvalidSignature + + def func_disallowed_qualifier() -> Final[int]: # type: ignore + pass # pragma: no cover + + with pytest.raises(InvalidSignature) as exc_info: + func_metadata(func_disallowed_qualifier) + assert "return annotation contains an invalid type qualifier" in str(exc_info.value) + + +def test_preserves_pydantic_metadata(): + def func_with_metadata() -> Annotated[int, Field(gt=1)]: ... # pragma: no branch + + meta = func_metadata(func_with_metadata) + + assert meta.output_schema is not None + assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"} diff --git a/tests/server/fastmcp/test_integration.py b/tests/server/fastmcp/test_integration.py new file mode 100644 index 0000000000..70948bd7e2 --- /dev/null +++ b/tests/server/fastmcp/test_integration.py @@ -0,0 +1,688 @@ +""" +Integration tests for FastMCP server functionality. + +These tests validate the proper functioning of FastMCP features using focused, +single-feature servers across different transports (SSE and StreamableHTTP). +""" +# TODO(Marcelo): The `examples` package is not being imported as package. We need to solve this. +# pyright: reportUnknownMemberType=false +# pyright: reportMissingImports=false +# pyright: reportUnknownVariableType=false +# pyright: reportUnknownArgumentType=false + +import json +import multiprocessing +import socket +from collections.abc import Generator + +import pytest +import uvicorn +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from pydantic import AnyUrl + +from examples.snippets.servers import ( + basic_prompt, + basic_resource, + basic_tool, + completion, + elicitation, + fastmcp_quickstart, + notifications, + sampling, + structured_output, + tool_progress, +) +from mcp.client.session import ClientSession +from mcp.client.sse import sse_client +from mcp.client.streamable_http import GetSessionIdCallback, streamable_http_client +from mcp.shared.context import RequestContext +from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder +from mcp.types import ( + ClientResult, + CreateMessageRequestParams, + CreateMessageResult, + ElicitRequestParams, + ElicitResult, + GetPromptResult, + InitializeResult, + LoggingMessageNotification, + LoggingMessageNotificationParams, + NotificationParams, + ProgressNotification, + ProgressNotificationParams, + ReadResourceResult, + ResourceListChangedNotification, + ServerNotification, + ServerRequest, + TextContent, + TextResourceContents, + ToolListChangedNotification, +) +from tests.test_helpers import wait_for_server + + +class NotificationCollector: + """Collects notifications from the server for testing.""" + + def __init__(self): + self.progress_notifications: list[ProgressNotificationParams] = [] + self.log_messages: list[LoggingMessageNotificationParams] = [] + self.resource_notifications: list[NotificationParams | None] = [] + self.tool_notifications: list[NotificationParams | None] = [] + + async def handle_generic_notification( + self, message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception + ) -> None: + """Handle any server notification and route to appropriate handler.""" + if isinstance(message, ServerNotification): # pragma: no branch + if isinstance(message.root, ProgressNotification): + self.progress_notifications.append(message.root.params) + elif isinstance(message.root, LoggingMessageNotification): + self.log_messages.append(message.root.params) + elif isinstance(message.root, ResourceListChangedNotification): + self.resource_notifications.append(message.root.params) + elif isinstance(message.root, ToolListChangedNotification): # pragma: no cover + self.tool_notifications.append(message.root.params) + + +# Common fixtures +@pytest.fixture +def server_port() -> int: + """Get a free port for testing.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def server_url(server_port: int) -> str: + """Get the server URL for testing.""" + return f"http://127.0.0.1:{server_port}" + + +def run_server_with_transport(module_name: str, port: int, transport: str) -> None: # pragma: no cover + """Run server with specified transport.""" + # Get the MCP instance based on module name + if module_name == "basic_tool": + mcp = basic_tool.mcp + elif module_name == "basic_resource": + mcp = basic_resource.mcp + elif module_name == "basic_prompt": + mcp = basic_prompt.mcp + elif module_name == "tool_progress": + mcp = tool_progress.mcp + elif module_name == "sampling": + mcp = sampling.mcp + elif module_name == "elicitation": + mcp = elicitation.mcp + elif module_name == "completion": + mcp = completion.mcp + elif module_name == "notifications": + mcp = notifications.mcp + elif module_name == "fastmcp_quickstart": + mcp = fastmcp_quickstart.mcp + elif module_name == "structured_output": + mcp = structured_output.mcp + else: + raise ImportError(f"Unknown module: {module_name}") + + # Create app based on transport type + if transport == "sse": + app = mcp.sse_app() + elif transport == "streamable-http": + app = mcp.streamable_http_app() + else: + raise ValueError(f"Invalid transport for test server: {transport}") + + server = uvicorn.Server(config=uvicorn.Config(app=app, host="127.0.0.1", port=port, log_level="error")) + print(f"Starting {transport} server on port {port}") + server.run() + + +@pytest.fixture +def server_transport(request: pytest.FixtureRequest, server_port: int) -> Generator[str, None, None]: + """Start server in a separate process with specified MCP instance and transport. + + Args: + request: pytest request with param tuple of (module_name, transport) + server_port: Port to run the server on + + Yields: + str: The transport type ('sse' or 'streamable_http') + """ + module_name, transport = request.param + + proc = multiprocessing.Process( + target=run_server_with_transport, + args=(module_name, server_port, transport), + daemon=True, + ) + proc.start() + + # Wait for server to be ready + wait_for_server(server_port) + + yield transport + + proc.kill() + proc.join(timeout=2) + if proc.is_alive(): # pragma: no cover + print("Server process failed to terminate") + + +# Helper function to create client based on transport +def create_client_for_transport(transport: str, server_url: str): + """Create the appropriate client context manager based on transport type.""" + if transport == "sse": + endpoint = f"{server_url}/sse" + return sse_client(endpoint) + elif transport == "streamable-http": + endpoint = f"{server_url}/mcp" + return streamable_http_client(endpoint) + else: # pragma: no cover + raise ValueError(f"Invalid transport: {transport}") + + +def unpack_streams( + client_streams: tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]] + | tuple[ + MemoryObjectReceiveStream[SessionMessage | Exception], + MemoryObjectSendStream[SessionMessage], + GetSessionIdCallback, + ], +): + """Unpack client streams handling different return values from SSE vs StreamableHTTP. + + SSE client returns (read_stream, write_stream) + StreamableHTTP client returns (read_stream, write_stream, session_id_callback) + + Args: + client_streams: Tuple from client context manager + + Returns: + Tuple of (read_stream, write_stream) + """ + if len(client_streams) == 2: + return client_streams + else: + read_stream, write_stream, _ = client_streams + return read_stream, write_stream + + +# Callback functions for testing +async def sampling_callback( + context: RequestContext[ClientSession, None], params: CreateMessageRequestParams +) -> CreateMessageResult: + """Sampling callback for tests.""" + return CreateMessageResult( + role="assistant", + content=TextContent( + type="text", + text="This is a simulated LLM response for testing", + ), + model="test-model", + ) + + +async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + """Elicitation callback for tests.""" + # For restaurant booking test + if "No tables available" in params.message: + return ElicitResult( + action="accept", + content={"checkAlternative": True, "alternativeDate": "2024-12-26"}, + ) + else: # pragma: no cover + return ElicitResult(action="decline") + + +# Test basic tools +@pytest.mark.anyio +@pytest.mark.parametrize( + "server_transport", + [ + ("basic_tool", "sse"), + ("basic_tool", "streamable-http"), + ], + indirect=True, +) +async def test_basic_tools(server_transport: str, server_url: str) -> None: + """Test basic tool functionality.""" + transport = server_transport + client_cm = create_client_for_transport(transport, server_url) + + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream) as session: + # Test initialization + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "Tool Example" + assert result.capabilities.tools is not None + + # Test sum tool + tool_result = await session.call_tool("sum", {"a": 5, "b": 3}) + assert len(tool_result.content) == 1 + assert isinstance(tool_result.content[0], TextContent) + assert tool_result.content[0].text == "8" + + # Test weather tool + weather_result = await session.call_tool("get_weather", {"city": "London"}) + assert len(weather_result.content) == 1 + assert isinstance(weather_result.content[0], TextContent) + assert "Weather in London: 22degreesC" in weather_result.content[0].text + + +# Test resources +@pytest.mark.anyio +@pytest.mark.parametrize( + "server_transport", + [ + ("basic_resource", "sse"), + ("basic_resource", "streamable-http"), + ], + indirect=True, +) +async def test_basic_resources(server_transport: str, server_url: str) -> None: + """Test basic resource functionality.""" + transport = server_transport + client_cm = create_client_for_transport(transport, server_url) + + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream) as session: + # Test initialization + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "Resource Example" + assert result.capabilities.resources is not None + + # Test document resource + doc_content = await session.read_resource(AnyUrl("file://documents/readme")) + assert isinstance(doc_content, ReadResourceResult) + assert len(doc_content.contents) == 1 + assert isinstance(doc_content.contents[0], TextResourceContents) + assert "Content of readme" in doc_content.contents[0].text + + # Test settings resource + settings_content = await session.read_resource(AnyUrl("config://settings")) + assert isinstance(settings_content, ReadResourceResult) + assert len(settings_content.contents) == 1 + assert isinstance(settings_content.contents[0], TextResourceContents) + settings_json = json.loads(settings_content.contents[0].text) + assert settings_json["theme"] == "dark" + assert settings_json["language"] == "en" + + +# Test prompts +@pytest.mark.anyio +@pytest.mark.parametrize( + "server_transport", + [ + ("basic_prompt", "sse"), + ("basic_prompt", "streamable-http"), + ], + indirect=True, +) +async def test_basic_prompts(server_transport: str, server_url: str) -> None: + """Test basic prompt functionality.""" + transport = server_transport + client_cm = create_client_for_transport(transport, server_url) + + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream) as session: + # Test initialization + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "Prompt Example" + assert result.capabilities.prompts is not None + + # Test review_code prompt + prompts = await session.list_prompts() + review_prompt = next((p for p in prompts.prompts if p.name == "review_code"), None) + assert review_prompt is not None + + prompt_result = await session.get_prompt("review_code", {"code": "def hello():\n print('Hello')"}) + assert isinstance(prompt_result, GetPromptResult) + assert len(prompt_result.messages) == 1 + assert isinstance(prompt_result.messages[0].content, TextContent) + assert "Please review this code:" in prompt_result.messages[0].content.text + assert "def hello():" in prompt_result.messages[0].content.text + + # Test debug_error prompt + debug_result = await session.get_prompt( + "debug_error", {"error": "TypeError: 'NoneType' object is not subscriptable"} + ) + assert isinstance(debug_result, GetPromptResult) + assert len(debug_result.messages) == 3 + assert debug_result.messages[0].role == "user" + assert isinstance(debug_result.messages[0].content, TextContent) + assert "I'm seeing this error:" in debug_result.messages[0].content.text + assert debug_result.messages[1].role == "user" + assert isinstance(debug_result.messages[1].content, TextContent) + assert "TypeError" in debug_result.messages[1].content.text + assert debug_result.messages[2].role == "assistant" + assert isinstance(debug_result.messages[2].content, TextContent) + assert "I'll help debug that" in debug_result.messages[2].content.text + + +# Test progress reporting +@pytest.mark.anyio +@pytest.mark.parametrize( + "server_transport", + [ + ("tool_progress", "sse"), + ("tool_progress", "streamable-http"), + ], + indirect=True, +) +async def test_tool_progress(server_transport: str, server_url: str) -> None: + """Test tool progress reporting.""" + transport = server_transport + collector = NotificationCollector() + + async def message_handler(message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception): + await collector.handle_generic_notification(message) + if isinstance(message, Exception): # pragma: no cover + raise message + + client_cm = create_client_for_transport(transport, server_url) + + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: + # Test initialization + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "Progress Example" + + # Test progress callback + progress_updates = [] + + async def progress_callback(progress: float, total: float | None, message: str | None) -> None: + progress_updates.append((progress, total, message)) + + # Call tool with progress + steps = 3 + tool_result = await session.call_tool( + "long_running_task", + {"task_name": "Test Task", "steps": steps}, + progress_callback=progress_callback, + ) + + assert len(tool_result.content) == 1 + assert isinstance(tool_result.content[0], TextContent) + assert "Task 'Test Task' completed" in tool_result.content[0].text + + # Verify progress updates + assert len(progress_updates) == steps + for i, (progress, total, message) in enumerate(progress_updates): + expected_progress = (i + 1) / steps + assert abs(progress - expected_progress) < 0.01 + assert total == 1.0 + assert f"Step {i + 1}/{steps}" in message + + # Verify log messages + assert len(collector.log_messages) > 0 + + +# Test sampling +@pytest.mark.anyio +@pytest.mark.parametrize( + "server_transport", + [ + ("sampling", "sse"), + ("sampling", "streamable-http"), + ], + indirect=True, +) +async def test_sampling(server_transport: str, server_url: str) -> None: + """Test sampling (LLM interaction) functionality.""" + transport = server_transport + client_cm = create_client_for_transport(transport, server_url) + + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream, sampling_callback=sampling_callback) as session: + # Test initialization + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "Sampling Example" + assert result.capabilities.tools is not None + + # Test sampling tool + sampling_result = await session.call_tool("generate_poem", {"topic": "nature"}) + assert len(sampling_result.content) == 1 + assert isinstance(sampling_result.content[0], TextContent) + assert "This is a simulated LLM response" in sampling_result.content[0].text + + +# Test elicitation +@pytest.mark.anyio +@pytest.mark.parametrize( + "server_transport", + [ + ("elicitation", "sse"), + ("elicitation", "streamable-http"), + ], + indirect=True, +) +async def test_elicitation(server_transport: str, server_url: str) -> None: + """Test elicitation (user interaction) functionality.""" + transport = server_transport + client_cm = create_client_for_transport(transport, server_url) + + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream, elicitation_callback=elicitation_callback) as session: + # Test initialization + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "Elicitation Example" + + # Test booking with unavailable date (triggers elicitation) + booking_result = await session.call_tool( + "book_table", + { + "date": "2024-12-25", # Unavailable date + "time": "19:00", + "party_size": 4, + }, + ) + assert len(booking_result.content) == 1 + assert isinstance(booking_result.content[0], TextContent) + assert "[SUCCESS] Booked for 2024-12-26" in booking_result.content[0].text + + # Test booking with available date (no elicitation) + booking_result = await session.call_tool( + "book_table", + { + "date": "2024-12-20", # Available date + "time": "20:00", + "party_size": 2, + }, + ) + assert len(booking_result.content) == 1 + assert isinstance(booking_result.content[0], TextContent) + assert "[SUCCESS] Booked for 2024-12-20 at 20:00" in booking_result.content[0].text + + +# Test notifications +@pytest.mark.anyio +@pytest.mark.parametrize( + "server_transport", + [ + ("notifications", "sse"), + ("notifications", "streamable-http"), + ], + indirect=True, +) +async def test_notifications(server_transport: str, server_url: str) -> None: + """Test notifications and logging functionality.""" + transport = server_transport + collector = NotificationCollector() + + async def message_handler(message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception): + await collector.handle_generic_notification(message) + if isinstance(message, Exception): # pragma: no cover + raise message + + client_cm = create_client_for_transport(transport, server_url) + + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: + # Test initialization + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "Notifications Example" + + # Call tool that generates notifications + tool_result = await session.call_tool("process_data", {"data": "test_data"}) + assert len(tool_result.content) == 1 + assert isinstance(tool_result.content[0], TextContent) + assert "Processed: test_data" in tool_result.content[0].text + + # Verify log messages at different levels + assert len(collector.log_messages) >= 4 + log_levels = {msg.level for msg in collector.log_messages} + assert "debug" in log_levels + assert "info" in log_levels + assert "warning" in log_levels + assert "error" in log_levels + + # Verify resource list changed notification + assert len(collector.resource_notifications) > 0 + + +# Test completion +@pytest.mark.anyio +@pytest.mark.parametrize( + "server_transport", + [ + ("completion", "sse"), + ("completion", "streamable-http"), + ], + indirect=True, +) +async def test_completion(server_transport: str, server_url: str) -> None: + """Test completion (autocomplete) functionality.""" + transport = server_transport + client_cm = create_client_for_transport(transport, server_url) + + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream) as session: + # Test initialization + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "Example" + assert result.capabilities.resources is not None + assert result.capabilities.prompts is not None + + # Test resource completion + from mcp.types import ResourceTemplateReference + + completion_result = await session.complete( + ref=ResourceTemplateReference(type="ref/resource", uri="github://repos/{owner}/{repo}"), + argument={"name": "repo", "value": ""}, + context_arguments={"owner": "modelcontextprotocol"}, + ) + + assert completion_result is not None + assert hasattr(completion_result, "completion") + assert completion_result.completion is not None + assert len(completion_result.completion.values) == 3 + assert "python-sdk" in completion_result.completion.values + assert "typescript-sdk" in completion_result.completion.values + assert "specification" in completion_result.completion.values + + # Test prompt completion + from mcp.types import PromptReference + + completion_result = await session.complete( + ref=PromptReference(type="ref/prompt", name="review_code"), + argument={"name": "language", "value": "py"}, + ) + + assert completion_result is not None + assert hasattr(completion_result, "completion") + assert completion_result.completion is not None + assert "python" in completion_result.completion.values + assert all(lang.startswith("py") for lang in completion_result.completion.values) + + +# Test FastMCP quickstart example +@pytest.mark.anyio +@pytest.mark.parametrize( + "server_transport", + [ + ("fastmcp_quickstart", "sse"), + ("fastmcp_quickstart", "streamable-http"), + ], + indirect=True, +) +async def test_fastmcp_quickstart(server_transport: str, server_url: str) -> None: + """Test FastMCP quickstart example.""" + transport = server_transport + client_cm = create_client_for_transport(transport, server_url) + + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream) as session: + # Test initialization + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "Demo" + + # Test add tool + tool_result = await session.call_tool("add", {"a": 10, "b": 20}) + assert len(tool_result.content) == 1 + assert isinstance(tool_result.content[0], TextContent) + assert tool_result.content[0].text == "30" + + # Test greeting resource directly + from pydantic import AnyUrl + + resource_result = await session.read_resource(AnyUrl("greeting://Alice")) + assert len(resource_result.contents) == 1 + assert isinstance(resource_result.contents[0], TextResourceContents) + assert resource_result.contents[0].text == "Hello, Alice!" + + +# Test structured output example +@pytest.mark.anyio +@pytest.mark.parametrize( + "server_transport", + [ + ("structured_output", "sse"), + ("structured_output", "streamable-http"), + ], + indirect=True, +) +async def test_structured_output(server_transport: str, server_url: str) -> None: + """Test structured output functionality.""" + transport = server_transport + client_cm = create_client_for_transport(transport, server_url) + + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream) as session: + # Test initialization + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "Structured Output Example" + + # Test get_weather tool + weather_result = await session.call_tool("get_weather", {"city": "New York"}) + assert len(weather_result.content) == 1 + assert isinstance(weather_result.content[0], TextContent) + + # Check that the result contains expected weather data + result_text = weather_result.content[0].text + assert "22.5" in result_text # temperature + assert "sunny" in result_text # condition + assert "45" in result_text # humidity + assert "5.2" in result_text # wind_speed diff --git a/tests/server/fastmcp/test_parameter_descriptions.py b/tests/server/fastmcp/test_parameter_descriptions.py index 29470ed19c..9f2386894c 100644 --- a/tests/server/fastmcp/test_parameter_descriptions.py +++ b/tests/server/fastmcp/test_parameter_descriptions.py @@ -14,7 +14,7 @@ async def test_parameter_descriptions(): def greet( name: str = Field(description="The name to greet"), title: str = Field(description="Optional title", default=""), - ) -> str: + ) -> str: # pragma: no cover """A greeting tool""" return f"Hello {title} {name}" diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index e76e59c52e..3935f3bd13 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -1,20 +1,27 @@ import base64 from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any +from unittest.mock import patch import pytest -from pydantic import AnyUrl +from pydantic import AnyUrl, BaseModel +from starlette.routing import Mount, Route from mcp.server.fastmcp import Context, FastMCP -from mcp.server.fastmcp.prompts.base import EmbeddedResource, Message, UserMessage +from mcp.server.fastmcp.prompts.base import Message, UserMessage from mcp.server.fastmcp.resources import FileResource, FunctionResource -from mcp.server.fastmcp.utilities.types import Image +from mcp.server.fastmcp.utilities.types import Audio, Image +from mcp.server.session import ServerSession +from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import McpError from mcp.shared.memory import ( create_connected_server_and_client_session as client_session, ) from mcp.types import ( + AudioContent, BlobResourceContents, + ContentBlock, + EmbeddedResource, ImageContent, TextContent, TextResourceContents, @@ -31,16 +38,93 @@ async def test_create_server(self): assert mcp.name == "FastMCP" assert mcp.instructions == "Server instructions" + @pytest.mark.anyio + async def test_normalize_path(self): + """Test path normalization for mount paths.""" + mcp = FastMCP() + + # Test root path + assert mcp._normalize_path("/", "/messages/") == "/messages/" + + # Test path with trailing slash + assert mcp._normalize_path("/github/", "/messages/") == "/github/messages/" + + # Test path without trailing slash + assert mcp._normalize_path("/github", "/messages/") == "/github/messages/" + + # Test endpoint without leading slash + assert mcp._normalize_path("/github", "messages/") == "/github/messages/" + + # Test both with trailing/leading slashes + assert mcp._normalize_path("/api/", "/v1/") == "/api/v1/" + + @pytest.mark.anyio + async def test_sse_app_with_mount_path(self): + """Test SSE app creation with different mount paths.""" + # Test with default mount path + mcp = FastMCP() + with patch.object(mcp, "_normalize_path", return_value="/messages/") as mock_normalize: + mcp.sse_app() + # Verify _normalize_path was called with correct args + mock_normalize.assert_called_once_with("/", "/messages/") + + # Test with custom mount path in settings + mcp = FastMCP() + mcp.settings.mount_path = "/custom" + with patch.object(mcp, "_normalize_path", return_value="/custom/messages/") as mock_normalize: + mcp.sse_app() + # Verify _normalize_path was called with correct args + mock_normalize.assert_called_once_with("/custom", "/messages/") + + # Test with mount_path parameter + mcp = FastMCP() + with patch.object(mcp, "_normalize_path", return_value="/param/messages/") as mock_normalize: + mcp.sse_app(mount_path="/param") + # Verify _normalize_path was called with correct args + mock_normalize.assert_called_once_with("/param", "/messages/") + + @pytest.mark.anyio + async def test_starlette_routes_with_mount_path(self): + """Test that Starlette routes are correctly configured with mount path.""" + # Test with mount path in settings + mcp = FastMCP() + mcp.settings.mount_path = "/api" + app = mcp.sse_app() + + # Find routes by type + sse_routes = [r for r in app.routes if isinstance(r, Route)] + mount_routes = [r for r in app.routes if isinstance(r, Mount)] + + # Verify routes exist + assert len(sse_routes) == 1, "Should have one SSE route" + assert len(mount_routes) == 1, "Should have one mount route" + + # Verify path values + assert sse_routes[0].path == "/sse", "SSE route path should be /sse" + assert mount_routes[0].path == "/messages", "Mount route path should be /messages" + + # Test with mount path as parameter + mcp = FastMCP() + app = mcp.sse_app(mount_path="/param") + + # Find routes by type + sse_routes = [r for r in app.routes if isinstance(r, Route)] + mount_routes = [r for r in app.routes if isinstance(r, Mount)] + + # Verify routes exist + assert len(sse_routes) == 1, "Should have one SSE route" + assert len(mount_routes) == 1, "Should have one mount route" + + # Verify path values + assert sse_routes[0].path == "/sse", "SSE route path should be /sse" + assert mount_routes[0].path == "/messages", "Mount route path should be /messages" + @pytest.mark.anyio async def test_non_ascii_description(self): """Test that FastMCP handles non-ASCII characters in descriptions correctly""" mcp = FastMCP() - @mcp.tool( - description=( - "🌟 This tool uses emojis and UTF-8 characters: á é í ó ú ñ 漢字 🎉" - ) - ) + @mcp.tool(description=("🌟 This tool uses emojis and UTF-8 characters: á é í ó ú ñ 漢字 🎉")) def hello_world(name: str = "世界") -> str: return f"¡Hola, {name}! 👋" @@ -64,7 +148,7 @@ async def test_add_tool_decorator(self): mcp = FastMCP() @mcp.tool() - def add(x: int, y: int) -> int: + def sum(x: int, y: int) -> int: # pragma: no cover return x + y assert len(mcp._tool_manager.list_tools()) == 1 @@ -76,7 +160,7 @@ async def test_add_tool_decorator_incorrect_usage(self): with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"): @mcp.tool # Missing parentheses #type: ignore - def add(x: int, y: int) -> int: + def sum(x: int, y: int) -> int: # pragma: no cover return x + y @pytest.mark.anyio @@ -84,7 +168,7 @@ async def test_add_resource_decorator(self): mcp = FastMCP() @mcp.resource("r://{x}") - def get_data(x: str) -> str: + def get_data(x: str) -> str: # pragma: no cover return f"Data: {x}" assert len(mcp._resource_manager._templates) == 1 @@ -93,15 +177,59 @@ def get_data(x: str) -> str: async def test_add_resource_decorator_incorrect_usage(self): mcp = FastMCP() - with pytest.raises( - TypeError, match="The @resource decorator was used incorrectly" - ): + with pytest.raises(TypeError, match="The @resource decorator was used incorrectly"): @mcp.resource # Missing parentheses #type: ignore - def get_data(x: str) -> str: + def get_data(x: str) -> str: # pragma: no cover return f"Data: {x}" +class TestDnsRebindingProtection: + """Tests for automatic DNS rebinding protection on localhost.""" + + def test_auto_enabled_for_127_0_0_1(self): + """DNS rebinding protection should auto-enable for host=127.0.0.1.""" + mcp = FastMCP(host="127.0.0.1") + assert mcp.settings.transport_security is not None + assert mcp.settings.transport_security.enable_dns_rebinding_protection is True + assert "127.0.0.1:*" in mcp.settings.transport_security.allowed_hosts + assert "localhost:*" in mcp.settings.transport_security.allowed_hosts + assert "http://127.0.0.1:*" in mcp.settings.transport_security.allowed_origins + assert "http://localhost:*" in mcp.settings.transport_security.allowed_origins + + def test_auto_enabled_for_localhost(self): + """DNS rebinding protection should auto-enable for host=localhost.""" + mcp = FastMCP(host="localhost") + assert mcp.settings.transport_security is not None + assert mcp.settings.transport_security.enable_dns_rebinding_protection is True + assert "127.0.0.1:*" in mcp.settings.transport_security.allowed_hosts + assert "localhost:*" in mcp.settings.transport_security.allowed_hosts + + def test_auto_enabled_for_ipv6_localhost(self): + """DNS rebinding protection should auto-enable for host=::1 (IPv6 localhost).""" + mcp = FastMCP(host="::1") + assert mcp.settings.transport_security is not None + assert mcp.settings.transport_security.enable_dns_rebinding_protection is True + assert "[::1]:*" in mcp.settings.transport_security.allowed_hosts + assert "http://[::1]:*" in mcp.settings.transport_security.allowed_origins + + def test_not_auto_enabled_for_other_hosts(self): + """DNS rebinding protection should NOT auto-enable for other hosts.""" + mcp = FastMCP(host="0.0.0.0") + assert mcp.settings.transport_security is None + + def test_explicit_settings_not_overridden(self): + """Explicit transport_security settings should not be overridden.""" + custom_settings = TransportSecuritySettings( + enable_dns_rebinding_protection=False, + ) + mcp = FastMCP(host="127.0.0.1", transport_security=custom_settings) + # Settings are copied by pydantic, so check values not identity + assert mcp.settings.transport_security is not None + assert mcp.settings.transport_security.enable_dns_rebinding_protection is False + assert mcp.settings.transport_security.allowed_hosts == [] + + def tool_fn(x: int, y: int) -> int: return x + y @@ -114,10 +242,15 @@ def image_tool_fn(path: str) -> Image: return Image(path) -def mixed_content_tool_fn() -> list[TextContent | ImageContent]: +def audio_tool_fn(path: str) -> Audio: + return Audio(path) + + +def mixed_content_tool_fn() -> list[ContentBlock]: return [ TextContent(type="text", text="Hello"), ImageContent(type="image", data="abc", mimeType="image/png"), + AudioContent(type="audio", data="def", mimeType="audio/wav"), ] @@ -193,6 +326,9 @@ async def test_tool_return_value_conversion(self): content = result.content[0] assert isinstance(content, TextContent) assert content.text == "3" + # Check structured content - int return type should have structured output + assert result.structuredContent is not None + assert result.structuredContent == {"result": 3} @pytest.mark.anyio async def test_tool_image_helper(self, tmp_path: Path): @@ -212,6 +348,62 @@ async def test_tool_image_helper(self, tmp_path: Path): # Verify base64 encoding decoded = base64.b64decode(content.data) assert decoded == b"fake png data" + # Check structured content - Image return type should NOT have structured output + assert result.structuredContent is None + + @pytest.mark.anyio + async def test_tool_audio_helper(self, tmp_path: Path): + # Create a test audio + audio_path = tmp_path / "test.wav" + audio_path.write_bytes(b"fake wav data") + + mcp = FastMCP() + mcp.add_tool(audio_tool_fn) + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("audio_tool_fn", {"path": str(audio_path)}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, AudioContent) + assert content.type == "audio" + assert content.mimeType == "audio/wav" + # Verify base64 encoding + decoded = base64.b64decode(content.data) + assert decoded == b"fake wav data" + # Check structured content - Image return type should NOT have structured output + assert result.structuredContent is None + + @pytest.mark.parametrize( + "filename,expected_mime_type", + [ + ("test.wav", "audio/wav"), + ("test.mp3", "audio/mpeg"), + ("test.ogg", "audio/ogg"), + ("test.flac", "audio/flac"), + ("test.aac", "audio/aac"), + ("test.m4a", "audio/mp4"), + ("test.unknown", "application/octet-stream"), # Unknown extension fallback + ], + ) + @pytest.mark.anyio + async def test_tool_audio_suffix_detection(self, tmp_path: Path, filename: str, expected_mime_type: str): + """Test that Audio helper correctly detects MIME types from file suffixes""" + mcp = FastMCP() + mcp.add_tool(audio_tool_fn) + + # Create a test audio file with the specific extension + audio_path = tmp_path / filename + audio_path.write_bytes(b"fake audio data") + + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("audio_tool_fn", {"path": str(audio_path)}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, AudioContent) + assert content.type == "audio" + assert content.mimeType == expected_mime_type + # Verify base64 encoding + decoded = base64.b64decode(content.data) + assert decoded == b"fake audio data" @pytest.mark.anyio async def test_tool_mixed_content(self): @@ -219,36 +411,59 @@ async def test_tool_mixed_content(self): mcp.add_tool(mixed_content_tool_fn) async with client_session(mcp._mcp_server) as client: result = await client.call_tool("mixed_content_tool_fn", {}) - assert len(result.content) == 2 - content1 = result.content[0] - content2 = result.content[1] + assert len(result.content) == 3 + content1, content2, content3 = result.content assert isinstance(content1, TextContent) assert content1.text == "Hello" assert isinstance(content2, ImageContent) assert content2.mimeType == "image/png" assert content2.data == "abc" + assert isinstance(content3, AudioContent) + assert content3.mimeType == "audio/wav" + assert content3.data == "def" + assert result.structuredContent is not None + assert "result" in result.structuredContent + structured_result = result.structuredContent["result"] + assert len(structured_result) == 3 + + expected_content = [ + {"type": "text", "text": "Hello"}, + {"type": "image", "data": "abc", "mimeType": "image/png"}, + {"type": "audio", "data": "def", "mimeType": "audio/wav"}, + ] + + for i, expected in enumerate(expected_content): + for key, value in expected.items(): + assert structured_result[i][key] == value @pytest.mark.anyio - async def test_tool_mixed_list_with_image(self, tmp_path: Path): + async def test_tool_mixed_list_with_audio_and_image(self, tmp_path: Path): """Test that lists containing Image objects and other types are handled correctly""" # Create a test image image_path = tmp_path / "test.png" image_path.write_bytes(b"test image data") - def mixed_list_fn() -> list: - return [ + # Create a test audio + audio_path = tmp_path / "test.wav" + audio_path.write_bytes(b"test audio data") + + # TODO(Marcelo): It seems if we add the proper type hint, it generates an invalid JSON schema. + # We need to fix this. + def mixed_list_fn() -> list: # type: ignore + return [ # type: ignore "text message", Image(image_path), + Audio(audio_path), {"key": "value"}, TextContent(type="text", text="direct content"), ] mcp = FastMCP() - mcp.add_tool(mixed_list_fn) + mcp.add_tool(mixed_list_fn) # type: ignore async with client_session(mcp._mcp_server) as client: result = await client.call_tool("mixed_list_fn", {}) - assert len(result.content) == 4 + assert len(result.content) == 5 # Check text conversion content1 = result.content[0] assert isinstance(content1, TextContent) @@ -258,14 +473,256 @@ def mixed_list_fn() -> list: assert isinstance(content2, ImageContent) assert content2.mimeType == "image/png" assert base64.b64decode(content2.data) == b"test image data" - # Check dict conversion + # Check audio conversion content3 = result.content[2] - assert isinstance(content3, TextContent) - assert '"key": "value"' in content3.text - # Check direct TextContent + assert isinstance(content3, AudioContent) + assert content3.mimeType == "audio/wav" + assert base64.b64decode(content3.data) == b"test audio data" + # Check dict conversion content4 = result.content[3] assert isinstance(content4, TextContent) - assert content4.text == "direct content" + assert '"key": "value"' in content4.text + # Check direct TextContent + content5 = result.content[4] + assert isinstance(content5, TextContent) + assert content5.text == "direct content" + # Check structured content - untyped list with Image objects should NOT have structured output + assert result.structuredContent is None + + @pytest.mark.anyio + async def test_tool_structured_output_basemodel(self): + """Test tool with structured output returning BaseModel""" + + class UserOutput(BaseModel): + name: str + age: int + active: bool = True + + def get_user(user_id: int) -> UserOutput: + """Get user by ID""" + return UserOutput(name="John Doe", age=30) + + mcp = FastMCP() + mcp.add_tool(get_user) + + async with client_session(mcp._mcp_server) as client: + # Check that the tool has outputSchema + tools = await client.list_tools() + tool = next(t for t in tools.tools if t.name == "get_user") + assert tool.outputSchema is not None + assert tool.outputSchema["type"] == "object" + assert "name" in tool.outputSchema["properties"] + assert "age" in tool.outputSchema["properties"] + + # Call the tool and check structured output + result = await client.call_tool("get_user", {"user_id": 123}) + assert result.isError is False + assert result.structuredContent is not None + assert result.structuredContent == {"name": "John Doe", "age": 30, "active": True} + # Content should be JSON serialized version + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert '"name": "John Doe"' in result.content[0].text + + @pytest.mark.anyio + async def test_tool_structured_output_primitive(self): + """Test tool with structured output returning primitive type""" + + def calculate_sum(a: int, b: int) -> int: + """Add two numbers""" + return a + b + + mcp = FastMCP() + mcp.add_tool(calculate_sum) + + async with client_session(mcp._mcp_server) as client: + # Check that the tool has outputSchema + tools = await client.list_tools() + tool = next(t for t in tools.tools if t.name == "calculate_sum") + assert tool.outputSchema is not None + # Primitive types are wrapped + assert tool.outputSchema["type"] == "object" + assert "result" in tool.outputSchema["properties"] + assert tool.outputSchema["properties"]["result"]["type"] == "integer" + + # Call the tool + result = await client.call_tool("calculate_sum", {"a": 5, "b": 7}) + assert result.isError is False + assert result.structuredContent is not None + assert result.structuredContent == {"result": 12} + + @pytest.mark.anyio + async def test_tool_structured_output_list(self): + """Test tool with structured output returning list""" + + def get_numbers() -> list[int]: + """Get a list of numbers""" + return [1, 2, 3, 4, 5] + + mcp = FastMCP() + mcp.add_tool(get_numbers) + + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("get_numbers", {}) + assert result.isError is False + assert result.structuredContent is not None + assert result.structuredContent == {"result": [1, 2, 3, 4, 5]} + + @pytest.mark.anyio + async def test_tool_structured_output_server_side_validation_error(self): + """Test that server-side validation errors are handled properly""" + + def get_numbers() -> list[int]: + return [1, 2, 3, 4, [5]] # type: ignore + + mcp = FastMCP() + mcp.add_tool(get_numbers) + + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("get_numbers", {}) + assert result.isError is True + assert result.structuredContent is None + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + + @pytest.mark.anyio + async def test_tool_structured_output_dict_str_any(self): + """Test tool with dict[str, Any] structured output""" + + def get_metadata() -> dict[str, Any]: + """Get metadata dictionary""" + return { + "version": "1.0.0", + "enabled": True, + "count": 42, + "tags": ["production", "stable"], + "config": {"nested": {"value": 123}}, + } + + mcp = FastMCP() + mcp.add_tool(get_metadata) + + async with client_session(mcp._mcp_server) as client: + # Check schema + tools = await client.list_tools() + tool = next(t for t in tools.tools if t.name == "get_metadata") + assert tool.outputSchema is not None + assert tool.outputSchema["type"] == "object" + # dict[str, Any] should have minimal schema + assert ( + "additionalProperties" not in tool.outputSchema or tool.outputSchema.get("additionalProperties") is True + ) + + # Call tool + result = await client.call_tool("get_metadata", {}) + assert result.isError is False + assert result.structuredContent is not None + expected = { + "version": "1.0.0", + "enabled": True, + "count": 42, + "tags": ["production", "stable"], + "config": {"nested": {"value": 123}}, + } + assert result.structuredContent == expected + + @pytest.mark.anyio + async def test_tool_structured_output_dict_str_typed(self): + """Test tool with dict[str, T] structured output for specific T""" + + def get_settings() -> dict[str, str]: + """Get settings as string dictionary""" + return {"theme": "dark", "language": "en", "timezone": "UTC"} + + mcp = FastMCP() + mcp.add_tool(get_settings) + + async with client_session(mcp._mcp_server) as client: + # Check schema + tools = await client.list_tools() + tool = next(t for t in tools.tools if t.name == "get_settings") + assert tool.outputSchema is not None + assert tool.outputSchema["type"] == "object" + assert tool.outputSchema["additionalProperties"]["type"] == "string" + + # Call tool + result = await client.call_tool("get_settings", {}) + assert result.isError is False + assert result.structuredContent == {"theme": "dark", "language": "en", "timezone": "UTC"} + + @pytest.mark.anyio + async def test_remove_tool(self): + """Test removing a tool from the server.""" + mcp = FastMCP() + mcp.add_tool(tool_fn) + + # Verify tool exists + assert len(mcp._tool_manager.list_tools()) == 1 + + # Remove the tool + mcp.remove_tool("tool_fn") + + # Verify tool is removed + assert len(mcp._tool_manager.list_tools()) == 0 + + @pytest.mark.anyio + async def test_remove_nonexistent_tool(self): + """Test that removing a non-existent tool raises ToolError.""" + from mcp.server.fastmcp.exceptions import ToolError + + mcp = FastMCP() + + with pytest.raises(ToolError, match="Unknown tool: nonexistent"): + mcp.remove_tool("nonexistent") + + @pytest.mark.anyio + async def test_remove_tool_and_list(self): + """Test that a removed tool doesn't appear in list_tools.""" + mcp = FastMCP() + mcp.add_tool(tool_fn) + mcp.add_tool(error_tool_fn) + + # Verify both tools exist + async with client_session(mcp._mcp_server) as client: + tools = await client.list_tools() + assert len(tools.tools) == 2 + tool_names = [t.name for t in tools.tools] + assert "tool_fn" in tool_names + assert "error_tool_fn" in tool_names + + # Remove one tool + mcp.remove_tool("tool_fn") + + # Verify only one tool remains + async with client_session(mcp._mcp_server) as client: + tools = await client.list_tools() + assert len(tools.tools) == 1 + assert tools.tools[0].name == "error_tool_fn" + + @pytest.mark.anyio + async def test_remove_tool_and_call(self): + """Test that calling a removed tool fails appropriately.""" + mcp = FastMCP() + mcp.add_tool(tool_fn) + + # Verify tool works before removal + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) + assert not result.isError + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == "3" + + # Remove the tool + mcp.remove_tool("tool_fn") + + # Verify calling removed tool returns an error + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) + assert result.isError + content = result.content[0] + assert isinstance(content, TextContent) + assert "Unknown tool" in content.text class TestServerResources: @@ -276,9 +733,7 @@ async def test_text_resource(self): def get_text(): return "Hello, world!" - resource = FunctionResource( - uri=AnyUrl("resource://test"), name="test", fn=get_text - ) + resource = FunctionResource(uri=AnyUrl("resource://test"), name="test", fn=get_text) mcp.add_resource(resource) async with client_session(mcp._mcp_server) as client: @@ -314,9 +769,7 @@ async def test_file_resource_text(self, tmp_path: Path): text_file = tmp_path / "test.txt" text_file.write_text("Hello from file!") - resource = FileResource( - uri=AnyUrl("file://test.txt"), name="test.txt", path=text_file - ) + resource = FileResource(uri=AnyUrl("file://test.txt"), name="test.txt", path=text_file) mcp.add_resource(resource) async with client_session(mcp._mcp_server) as client: @@ -343,10 +796,25 @@ async def test_file_resource_binary(self, tmp_path: Path): async with client_session(mcp._mcp_server) as client: result = await client.read_resource(AnyUrl("file://test.bin")) assert isinstance(result.contents[0], BlobResourceContents) - assert ( - result.contents[0].blob - == base64.b64encode(b"Binary file data").decode() - ) + assert result.contents[0].blob == base64.b64encode(b"Binary file data").decode() + + @pytest.mark.anyio + async def test_function_resource(self): + mcp = FastMCP() + + @mcp.resource("function://test", name="test_get_data") + def get_data() -> str: # pragma: no cover + """get_data returns a string""" + return "Hello, world!" + + async with client_session(mcp._mcp_server) as client: + resources = await client.list_resources() + assert len(resources.resources) == 1 + resource = resources.resources[0] + assert resource.description == "get_data returns a string" + assert resource.uri == AnyUrl("function://test") + assert resource.name == "test_get_data" + assert resource.mimeType == "text/plain" class TestServerResourceTemplates: @@ -359,7 +827,7 @@ async def test_resource_with_params(self): with pytest.raises(ValueError, match="Mismatch between URI parameters"): @mcp.resource("resource://data") - def get_data_fn(param: str) -> str: + def get_data_fn(param: str) -> str: # pragma: no cover return f"Data: {param}" @pytest.mark.anyio @@ -370,7 +838,7 @@ async def test_resource_with_uri_params(self): with pytest.raises(ValueError, match="Mismatch between URI parameters"): @mcp.resource("resource://{param}") - def get_data() -> str: + def get_data() -> str: # pragma: no cover return "Data" @pytest.mark.anyio @@ -379,7 +847,7 @@ async def test_resource_with_untyped_params(self): mcp = FastMCP() @mcp.resource("resource://{param}") - def get_data(param) -> str: + def get_data(param) -> str: # type: ignore # pragma: no cover return "Data" @pytest.mark.anyio @@ -404,7 +872,7 @@ async def test_resource_mismatched_params(self): with pytest.raises(ValueError, match="Mismatch between URI parameters"): @mcp.resource("resource://{name}/data") - def get_data(user: str) -> str: + def get_data(user: str) -> str: # pragma: no cover return f"Data for {user}" @pytest.mark.anyio @@ -417,9 +885,7 @@ def get_data(org: str, repo: str) -> str: return f"Data for {org}/{repo}" async with client_session(mcp._mcp_server) as client: - result = await client.read_resource( - AnyUrl("resource://cursor/fastmcp/data") - ) + result = await client.read_resource(AnyUrl("resource://cursor/fastmcp/data")) assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Data for cursor/fastmcp" @@ -431,10 +897,10 @@ async def test_resource_multiple_mismatched_params(self): with pytest.raises(ValueError, match="Mismatch between URI parameters"): @mcp.resource("resource://{org}/{repo}/data") - def get_data_mismatched(org: str, repo_2: str) -> str: + def get_data_mismatched(org: str, repo_2: str) -> str: # pragma: no cover return f"Data for {org}" - """Test that a resource with no parameters works as a regular resource""" + """Test that a resource with no parameters works as a regular resource""" # pragma: no cover mcp = FastMCP() @mcp.resource("resource://static") @@ -465,16 +931,37 @@ def get_data(name: str) -> str: result = await resource.read() assert result == "Data for test" + @pytest.mark.anyio + async def test_resource_template_includes_mime_type(self): + """Test that list resource templates includes the correct mimeType.""" + mcp = FastMCP() + + @mcp.resource("resource://{user}/csv", mime_type="text/csv") + def get_csv(user: str) -> str: + return f"csv for {user}" + + templates = await mcp.list_resource_templates() + assert len(templates) == 1 + template = templates[0] + + assert hasattr(template, "mimeType") + assert template.mimeType == "text/csv" + + async with client_session(mcp._mcp_server) as client: + result = await client.read_resource(AnyUrl("resource://bob/csv")) + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "csv for bob" + class TestContextInjection: - """Test context injection in tools.""" + """Test context injection in tools, resources, and prompts.""" @pytest.mark.anyio async def test_context_detection(self): """Test that context parameters are properly detected.""" mcp = FastMCP() - def tool_with_context(x: int, ctx: Context) -> str: + def tool_with_context(x: int, ctx: Context[ServerSession, None]) -> str: # pragma: no cover return f"Request {ctx.request_id}: {x}" tool = mcp._tool_manager.add_tool(tool_with_context) @@ -485,7 +972,7 @@ async def test_context_injection(self): """Test that context is properly injected into tool calls.""" mcp = FastMCP() - def tool_with_context(x: int, ctx: Context) -> str: + def tool_with_context(x: int, ctx: Context[ServerSession, None]) -> str: assert ctx.request_id is not None return f"Request {ctx.request_id}: {x}" @@ -503,7 +990,7 @@ async def test_async_context(self): """Test that context works in async functions.""" mcp = FastMCP() - async def async_tool(x: int, ctx: Context) -> str: + async def async_tool(x: int, ctx: Context[ServerSession, None]) -> str: assert ctx.request_id is not None return f"Async request {ctx.request_id}: {x}" @@ -518,14 +1005,10 @@ async def async_tool(x: int, ctx: Context) -> str: @pytest.mark.anyio async def test_context_logging(self): - from unittest.mock import patch - - import mcp.server.session - """Test that context logging methods work.""" mcp = FastMCP() - async def logging_tool(msg: str, ctx: Context) -> str: + async def logging_tool(msg: str, ctx: Context[ServerSession, None]) -> str: await ctx.debug("Debug message") await ctx.info("Info message") await ctx.warning("Warning message") @@ -544,14 +1027,28 @@ async def logging_tool(msg: str, ctx: Context) -> str: assert mock_log.call_count == 4 mock_log.assert_any_call( - level="debug", data="Debug message", logger=None + level="debug", + data="Debug message", + logger=None, + related_request_id="1", + ) + mock_log.assert_any_call( + level="info", + data="Info message", + logger=None, + related_request_id="1", ) - mock_log.assert_any_call(level="info", data="Info message", logger=None) mock_log.assert_any_call( - level="warning", data="Warning message", logger=None + level="warning", + data="Warning message", + logger=None, + related_request_id="1", ) mock_log.assert_any_call( - level="error", data="Error message", logger=None + level="error", + data="Error message", + logger=None, + related_request_id="1", ) @pytest.mark.anyio @@ -580,7 +1077,7 @@ def test_resource() -> str: return "resource data" @mcp.tool() - async def tool_with_resource(ctx: Context) -> str: + async def tool_with_resource(ctx: Context[ServerSession, None]) -> str: r_iter = await ctx.read_resource("test://data") r_list = list(r_iter) assert len(r_list) == 1 @@ -594,6 +1091,126 @@ async def tool_with_resource(ctx: Context) -> str: assert isinstance(content, TextContent) assert "Read resource: resource data" in content.text + @pytest.mark.anyio + async def test_resource_with_context(self): + """Test that resources can receive context parameter.""" + mcp = FastMCP() + + @mcp.resource("resource://context/{name}") + def resource_with_context(name: str, ctx: Context[ServerSession, None]) -> str: + """Resource that receives context.""" + assert ctx is not None + return f"Resource {name} - context injected" + + # Verify template has context_kwarg set + templates = mcp._resource_manager.list_templates() + assert len(templates) == 1 + template = templates[0] + assert hasattr(template, "context_kwarg") + assert template.context_kwarg == "ctx" + + # Test via client + async with client_session(mcp._mcp_server) as client: + result = await client.read_resource(AnyUrl("resource://context/test")) + assert len(result.contents) == 1 + content = result.contents[0] + assert isinstance(content, TextResourceContents) + # Should have either request_id or indication that context was injected + assert "Resource test - context injected" == content.text + + @pytest.mark.anyio + async def test_resource_without_context(self): + """Test that resources without context work normally.""" + mcp = FastMCP() + + @mcp.resource("resource://nocontext/{name}") + def resource_no_context(name: str) -> str: + """Resource without context.""" + return f"Resource {name} works" + + # Verify template has no context_kwarg + templates = mcp._resource_manager.list_templates() + assert len(templates) == 1 + template = templates[0] + assert template.context_kwarg is None + + # Test via client + async with client_session(mcp._mcp_server) as client: + result = await client.read_resource(AnyUrl("resource://nocontext/test")) + assert len(result.contents) == 1 + content = result.contents[0] + assert isinstance(content, TextResourceContents) + assert content.text == "Resource test works" + + @pytest.mark.anyio + async def test_resource_context_custom_name(self): + """Test resource context with custom parameter name.""" + mcp = FastMCP() + + @mcp.resource("resource://custom/{id}") + def resource_custom_ctx(id: str, my_ctx: Context[ServerSession, None]) -> str: + """Resource with custom context parameter name.""" + assert my_ctx is not None + return f"Resource {id} with context" + + # Verify template detects custom context parameter + templates = mcp._resource_manager.list_templates() + assert len(templates) == 1 + template = templates[0] + assert template.context_kwarg == "my_ctx" + + # Test via client + async with client_session(mcp._mcp_server) as client: + result = await client.read_resource(AnyUrl("resource://custom/123")) + assert len(result.contents) == 1 + content = result.contents[0] + assert isinstance(content, TextResourceContents) + assert "Resource 123 with context" in content.text + + @pytest.mark.anyio + async def test_prompt_with_context(self): + """Test that prompts can receive context parameter.""" + mcp = FastMCP() + + @mcp.prompt("prompt_with_ctx") + def prompt_with_context(text: str, ctx: Context[ServerSession, None]) -> str: + """Prompt that expects context.""" + assert ctx is not None + return f"Prompt '{text}' - context injected" + + # Check if prompt has context parameter detection + prompts = mcp._prompt_manager.list_prompts() + assert len(prompts) == 1 + + # Test via client + async with client_session(mcp._mcp_server) as client: + # Try calling without passing ctx explicitly + result = await client.get_prompt("prompt_with_ctx", {"text": "test"}) + # If this succeeds, check if context was injected + assert len(result.messages) == 1 + content = result.messages[0].content + assert isinstance(content, TextContent) + assert "Prompt 'test' - context injected" in content.text + + @pytest.mark.anyio + async def test_prompt_without_context(self): + """Test that prompts without context work normally.""" + mcp = FastMCP() + + @mcp.prompt("prompt_no_ctx") + def prompt_no_context(text: str) -> str: + """Prompt without context.""" + return f"Prompt '{text}' works" + + # Test via client + async with client_session(mcp._mcp_server) as client: + result = await client.get_prompt("prompt_no_ctx", {"text": "test"}) + assert len(result.messages) == 1 + message = result.messages[0] + content = message.content + assert isinstance(content, TextContent) + assert content.text == "Prompt 'test' works" + class TestServerPrompts: """Test prompt functionality in FastMCP server.""" @@ -653,7 +1270,7 @@ def test_prompt_decorator_error(self): with pytest.raises(TypeError, match="decorator was used incorrectly"): @mcp.prompt # type: ignore - def fn() -> str: + def fn() -> str: # pragma: no cover return "Hello, world!" @pytest.mark.anyio @@ -662,7 +1279,7 @@ async def test_list_prompts(self): mcp = FastMCP() @mcp.prompt() - def fn(name: str, optional: str = "default") -> str: + def fn(name: str, optional: str = "default") -> str: # pragma: no cover return f"Hello, {name}!" async with client_session(mcp._mcp_server) as client: @@ -696,6 +1313,46 @@ def fn(name: str) -> str: assert isinstance(content, TextContent) assert content.text == "Hello, World!" + @pytest.mark.anyio + async def test_get_prompt_with_description(self): + """Test getting a prompt through MCP protocol.""" + mcp = FastMCP() + + @mcp.prompt(description="Test prompt description") + def fn(name: str) -> str: + return f"Hello, {name}!" + + async with client_session(mcp._mcp_server) as client: + result = await client.get_prompt("fn", {"name": "World"}) + assert result.description == "Test prompt description" + + @pytest.mark.anyio + async def test_get_prompt_without_description(self): + """Test getting a prompt without description returns empty string.""" + mcp = FastMCP() + + @mcp.prompt() + def fn(name: str) -> str: + return f"Hello, {name}!" + + async with client_session(mcp._mcp_server) as client: + result = await client.get_prompt("fn", {"name": "World"}) + assert result.description == "" + + @pytest.mark.anyio + async def test_get_prompt_with_docstring_description(self): + """Test prompt uses docstring as description when not explicitly provided.""" + mcp = FastMCP() + + @mcp.prompt() + def fn(name: str) -> str: + """This is the function docstring.""" + return f"Hello, {name}!" + + async with client_session(mcp._mcp_server) as client: + result = await client.get_prompt("fn", {"name": "World"}) + assert result.description == "This is the function docstring." + @pytest.mark.anyio async def test_get_prompt_with_resource(self): """Test getting a prompt that returns resource content.""" @@ -740,9 +1397,28 @@ async def test_get_prompt_missing_args(self): mcp = FastMCP() @mcp.prompt() - def prompt_fn(name: str) -> str: + def prompt_fn(name: str) -> str: # pragma: no cover return f"Hello, {name}!" async with client_session(mcp._mcp_server) as client: with pytest.raises(McpError, match="Missing required arguments"): await client.get_prompt("prompt_fn") + + +def test_streamable_http_no_redirect() -> None: + """Test that streamable HTTP routes are correctly configured.""" + mcp = FastMCP() + app = mcp.streamable_http_app() + + # Find routes by type - streamable_http_app creates Route objects, not Mount objects + streamable_routes = [ + r + for r in app.routes + if isinstance(r, Route) and hasattr(r, "path") and r.path == mcp.settings.streamable_http_path + ] + + # Verify routes exist + assert len(streamable_routes) == 1, "Should have one streamable route" + + # Verify path values + assert streamable_routes[0].path == "/mcp", "Streamable route path should be /mcp" diff --git a/tests/server/fastmcp/test_title.py b/tests/server/fastmcp/test_title.py new file mode 100644 index 0000000000..7cac570123 --- /dev/null +++ b/tests/server/fastmcp/test_title.py @@ -0,0 +1,215 @@ +"""Integration tests for title field functionality.""" + +import pytest +from pydantic import AnyUrl + +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.resources import FunctionResource +from mcp.shared.memory import create_connected_server_and_client_session +from mcp.shared.metadata_utils import get_display_name +from mcp.types import Prompt, Resource, ResourceTemplate, Tool, ToolAnnotations + + +@pytest.mark.anyio +async def test_tool_title_precedence(): + """Test that tool title precedence works correctly: title > annotations.title > name.""" + # Create server with various tool configurations + mcp = FastMCP(name="TitleTestServer") + + # Tool with only name + @mcp.tool(description="Basic tool") + def basic_tool(message: str) -> str: # pragma: no cover + return message + + # Tool with title + @mcp.tool(description="Tool with title", title="User-Friendly Tool") + def tool_with_title(message: str) -> str: # pragma: no cover + return message + + # Tool with annotations.title (when title is not supported on decorator) + # We'll need to add this manually after registration + @mcp.tool(description="Tool with annotations") + def tool_with_annotations(message: str) -> str: # pragma: no cover + return message + + # Tool with both title and annotations.title + @mcp.tool(description="Tool with both", title="Primary Title") + def tool_with_both(message: str) -> str: # pragma: no cover + return message + + # Start server and connect client + async with create_connected_server_and_client_session(mcp._mcp_server) as client: + await client.initialize() + + # List tools + tools_result = await client.list_tools() + tools = {tool.name: tool for tool in tools_result.tools} + + # Verify basic tool uses name + assert "basic_tool" in tools + basic = tools["basic_tool"] + # Since we haven't implemented get_display_name yet, we'll check the raw fields + assert basic.title is None + assert basic.name == "basic_tool" + + # Verify tool with title + assert "tool_with_title" in tools + titled = tools["tool_with_title"] + assert titled.title == "User-Friendly Tool" + + # For now, we'll skip the annotations.title test as it requires modifying + # the tool after registration, which we'll implement later + + # Verify tool with both uses title over annotations.title + assert "tool_with_both" in tools + both = tools["tool_with_both"] + assert both.title == "Primary Title" + + +@pytest.mark.anyio +async def test_prompt_title(): + """Test that prompt titles work correctly.""" + mcp = FastMCP(name="PromptTitleServer") + + # Prompt with only name + @mcp.prompt(description="Basic prompt") + def basic_prompt(topic: str) -> str: # pragma: no cover + return f"Tell me about {topic}" + + # Prompt with title + @mcp.prompt(description="Titled prompt", title="Ask About Topic") + def titled_prompt(topic: str) -> str: # pragma: no cover + return f"Tell me about {topic}" + + # Start server and connect client + async with create_connected_server_and_client_session(mcp._mcp_server) as client: + await client.initialize() + + # List prompts + prompts_result = await client.list_prompts() + prompts = {prompt.name: prompt for prompt in prompts_result.prompts} + + # Verify basic prompt uses name + assert "basic_prompt" in prompts + basic = prompts["basic_prompt"] + assert basic.title is None + assert basic.name == "basic_prompt" + + # Verify prompt with title + assert "titled_prompt" in prompts + titled = prompts["titled_prompt"] + assert titled.title == "Ask About Topic" + + +@pytest.mark.anyio +async def test_resource_title(): + """Test that resource titles work correctly.""" + mcp = FastMCP(name="ResourceTitleServer") + + # Static resource without title + def get_basic_data() -> str: # pragma: no cover + return "Basic data" + + basic_resource = FunctionResource( + uri=AnyUrl("resource://basic"), + name="basic_resource", + description="Basic resource", + fn=get_basic_data, + ) + mcp.add_resource(basic_resource) + + # Static resource with title + def get_titled_data() -> str: # pragma: no cover + return "Titled data" + + titled_resource = FunctionResource( + uri=AnyUrl("resource://titled"), + name="titled_resource", + title="User-Friendly Resource", + description="Resource with title", + fn=get_titled_data, + ) + mcp.add_resource(titled_resource) + + # Dynamic resource without title + @mcp.resource("resource://dynamic/{id}") + def dynamic_resource(id: str) -> str: # pragma: no cover + return f"Data for {id}" + + # Dynamic resource with title (when supported) + @mcp.resource("resource://titled-dynamic/{id}", title="Dynamic Data") + def titled_dynamic_resource(id: str) -> str: # pragma: no cover + return f"Data for {id}" + + # Start server and connect client + async with create_connected_server_and_client_session(mcp._mcp_server) as client: + await client.initialize() + + # List resources + resources_result = await client.list_resources() + resources = {str(res.uri): res for res in resources_result.resources} + + # Verify basic resource uses name + assert "resource://basic" in resources + basic = resources["resource://basic"] + assert basic.title is None + assert basic.name == "basic_resource" + + # Verify resource with title + assert "resource://titled" in resources + titled = resources["resource://titled"] + assert titled.title == "User-Friendly Resource" + + # List resource templates + templates_result = await client.list_resource_templates() + templates = {tpl.uriTemplate: tpl for tpl in templates_result.resourceTemplates} + + # Verify dynamic resource template + assert "resource://dynamic/{id}" in templates + dynamic = templates["resource://dynamic/{id}"] + assert dynamic.title is None + assert dynamic.name == "dynamic_resource" + + # Verify titled dynamic resource template (when supported) + if "resource://titled-dynamic/{id}" in templates: # pragma: no branch + titled_dynamic = templates["resource://titled-dynamic/{id}"] + assert titled_dynamic.title == "Dynamic Data" + + +@pytest.mark.anyio +async def test_get_display_name_utility(): + """Test the get_display_name utility function.""" + + # Test tool precedence: title > annotations.title > name + tool_name_only = Tool(name="test_tool", inputSchema={}) + assert get_display_name(tool_name_only) == "test_tool" + + tool_with_title = Tool(name="test_tool", title="Test Tool", inputSchema={}) + assert get_display_name(tool_with_title) == "Test Tool" + + tool_with_annotations = Tool(name="test_tool", inputSchema={}, annotations=ToolAnnotations(title="Annotated Tool")) + assert get_display_name(tool_with_annotations) == "Annotated Tool" + + tool_with_both = Tool( + name="test_tool", title="Primary Title", inputSchema={}, annotations=ToolAnnotations(title="Secondary Title") + ) + assert get_display_name(tool_with_both) == "Primary Title" + + # Test other types: title > name + resource = Resource(uri=AnyUrl("file://test"), name="test_res") + assert get_display_name(resource) == "test_res" + + resource_with_title = Resource(uri=AnyUrl("file://test"), name="test_res", title="Test Resource") + assert get_display_name(resource_with_title) == "Test Resource" + + prompt = Prompt(name="test_prompt") + assert get_display_name(prompt) == "test_prompt" + + prompt_with_title = Prompt(name="test_prompt", title="Test Prompt") + assert get_display_name(prompt_with_title) == "Test Prompt" + + template = ResourceTemplate(uriTemplate="file://{id}", name="test_template") + assert get_display_name(template) == "test_template" + + template_with_title = ResourceTemplate(uriTemplate="file://{id}", name="test_template", title="Test Template") + assert get_display_name(template_with_title) == "Test Template" diff --git a/tests/server/fastmcp/test_tool_manager.py b/tests/server/fastmcp/test_tool_manager.py index 8f52e3d852..d83d484744 100644 --- a/tests/server/fastmcp/test_tool_manager.py +++ b/tests/server/fastmcp/test_tool_manager.py @@ -1,40 +1,74 @@ import json import logging +from dataclasses import dataclass +from typing import Any, TypedDict import pytest from pydantic import BaseModel from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp.exceptions import ToolError -from mcp.server.fastmcp.tools import ToolManager +from mcp.server.fastmcp.tools import Tool, ToolManager +from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata from mcp.server.session import ServerSessionT -from mcp.shared.context import LifespanContextT +from mcp.shared.context import LifespanContextT, RequestT +from mcp.types import TextContent, ToolAnnotations class TestAddTools: def test_basic_function(self): """Test registering and running a basic function.""" - def add(a: int, b: int) -> int: + def sum(a: int, b: int) -> int: # pragma: no cover """Add two numbers.""" return a + b manager = ToolManager() - manager.add_tool(add) + manager.add_tool(sum) - tool = manager.get_tool("add") + tool = manager.get_tool("sum") assert tool is not None - assert tool.name == "add" + assert tool.name == "sum" assert tool.description == "Add two numbers." assert tool.is_async is False assert tool.parameters["properties"]["a"]["type"] == "integer" assert tool.parameters["properties"]["b"]["type"] == "integer" + def test_init_with_tools(self, caplog: pytest.LogCaptureFixture): + def sum(a: int, b: int) -> int: # pragma: no cover + return a + b + + class AddArguments(ArgModelBase): + a: int + b: int + + fn_metadata = FuncMetadata(arg_model=AddArguments) + + original_tool = Tool( + name="sum", + title="Add Tool", + description="Add two numbers.", + fn=sum, + fn_metadata=fn_metadata, + is_async=False, + parameters=AddArguments.model_json_schema(), + context_kwarg=None, + annotations=None, + ) + manager = ToolManager(tools=[original_tool]) + saved_tool = manager.get_tool("sum") + assert saved_tool == original_tool + + # warn on duplicate tools + with caplog.at_level(logging.WARNING): + manager = ToolManager(True, tools=[original_tool, original_tool]) + assert "Tool already exists: sum" in caplog.text + @pytest.mark.anyio async def test_async_function(self): """Test registering and running an async function.""" - async def fetch_data(url: str) -> str: + async def fetch_data(url: str) -> str: # pragma: no cover """Fetch data from URL.""" return f"Data from {url}" @@ -55,7 +89,7 @@ class UserInput(BaseModel): name: str age: int - def create_user(user: UserInput, flag: bool) -> dict: + def create_user(user: UserInput, flag: bool) -> dict[str, Any]: # pragma: no cover """Create a new user.""" return {"id": 1, **user.model_dump()} @@ -71,6 +105,39 @@ def create_user(user: UserInput, flag: bool) -> dict: assert "age" in tool.parameters["$defs"]["UserInput"]["properties"] assert "flag" in tool.parameters["properties"] + def test_add_callable_object(self): + """Test registering a callable object.""" + + class MyTool: + def __init__(self): + self.__name__ = "MyTool" + + def __call__(self, x: int) -> int: # pragma: no cover + return x * 2 + + manager = ToolManager() + tool = manager.add_tool(MyTool()) + assert tool.name == "MyTool" + assert tool.is_async is False + assert tool.parameters["properties"]["x"]["type"] == "integer" + + @pytest.mark.anyio + async def test_add_async_callable_object(self): + """Test registering an async callable object.""" + + class MyAsyncTool: + def __init__(self): + self.__name__ = "MyAsyncTool" + + async def __call__(self, x: int) -> int: # pragma: no cover + return x * 2 + + manager = ToolManager() + tool = manager.add_tool(MyAsyncTool()) + assert tool.name == "MyAsyncTool" + assert tool.is_async is True + assert tool.parameters["properties"]["x"]["type"] == "integer" + def test_add_invalid_tool(self): manager = ToolManager() with pytest.raises(AttributeError): @@ -78,20 +145,18 @@ def test_add_invalid_tool(self): def test_add_lambda(self): manager = ToolManager() - tool = manager.add_tool(lambda x: x, name="my_tool") + tool = manager.add_tool(lambda x: x, name="my_tool") # type: ignore[reportUnknownLambdaType] assert tool.name == "my_tool" def test_add_lambda_with_no_name(self): manager = ToolManager() - with pytest.raises( - ValueError, match="You must provide a name for lambda functions" - ): - manager.add_tool(lambda x: x) + with pytest.raises(ValueError, match="You must provide a name for lambda functions"): + manager.add_tool(lambda x: x) # type: ignore[reportUnknownLambdaType] - def test_warn_on_duplicate_tools(self, caplog): + def test_warn_on_duplicate_tools(self, caplog: pytest.LogCaptureFixture): """Test warning on duplicate tools.""" - def f(x: int) -> int: + def f(x: int) -> int: # pragma: no cover return x manager = ToolManager() @@ -100,10 +165,10 @@ def f(x: int) -> int: manager.add_tool(f) assert "Tool already exists: f" in caplog.text - def test_disable_warn_on_duplicate_tools(self, caplog): + def test_disable_warn_on_duplicate_tools(self, caplog: pytest.LogCaptureFixture): """Test disabling warning on duplicate tools.""" - def f(x: int) -> int: + def f(x: int) -> int: # pragma: no cover return x manager = ToolManager() @@ -117,18 +182,18 @@ def f(x: int) -> int: class TestCallTools: @pytest.mark.anyio async def test_call_tool(self): - def add(a: int, b: int) -> int: + def sum(a: int, b: int) -> int: # pragma: no cover """Add two numbers.""" return a + b manager = ToolManager() - manager.add_tool(add) - result = await manager.call_tool("add", {"a": 1, "b": 2}) + manager.add_tool(sum) + result = await manager.call_tool("sum", {"a": 1, "b": 2}) assert result == 3 @pytest.mark.anyio async def test_call_async_tool(self): - async def double(n: int) -> int: + async def double(n: int) -> int: # pragma: no cover """Double a number.""" return n * 2 @@ -137,27 +202,55 @@ async def double(n: int) -> int: result = await manager.call_tool("double", {"n": 5}) assert result == 10 + @pytest.mark.anyio + async def test_call_object_tool(self): + class MyTool: + def __init__(self): + self.__name__ = "MyTool" + + def __call__(self, x: int) -> int: + return x * 2 + + manager = ToolManager() + tool = manager.add_tool(MyTool()) + result = await tool.run({"x": 5}) + assert result == 10 + + @pytest.mark.anyio + async def test_call_async_object_tool(self): + class MyAsyncTool: + def __init__(self): + self.__name__ = "MyAsyncTool" + + async def __call__(self, x: int) -> int: + return x * 2 + + manager = ToolManager() + tool = manager.add_tool(MyAsyncTool()) + result = await tool.run({"x": 5}) + assert result == 10 + @pytest.mark.anyio async def test_call_tool_with_default_args(self): - def add(a: int, b: int = 1) -> int: + def sum(a: int, b: int = 1) -> int: """Add two numbers.""" return a + b manager = ToolManager() - manager.add_tool(add) - result = await manager.call_tool("add", {"a": 1}) + manager.add_tool(sum) + result = await manager.call_tool("sum", {"a": 1}) assert result == 2 @pytest.mark.anyio async def test_call_tool_with_missing_args(self): - def add(a: int, b: int) -> int: + def sum(a: int, b: int) -> int: # pragma: no cover """Add two numbers.""" return a + b manager = ToolManager() - manager.add_tool(add) + manager.add_tool(sum) with pytest.raises(ToolError): - await manager.call_tool("add", {"a": 1}) + await manager.call_tool("sum", {"a": 1}) @pytest.mark.anyio async def test_call_unknown_tool(self): @@ -167,7 +260,7 @@ async def test_call_unknown_tool(self): @pytest.mark.anyio async def test_call_tool_with_list_int_input(self): - def sum_vals(vals: list[int]) -> int: + def sum_vals(vals: list[int]) -> int: # pragma: no cover return sum(vals) manager = ToolManager() @@ -180,7 +273,7 @@ def sum_vals(vals: list[int]) -> int: @pytest.mark.anyio async def test_call_tool_with_list_str_or_str_input(self): - def concat_strs(vals: list[str] | str) -> str: + def concat_strs(vals: list[str] | str) -> str: # pragma: no cover return vals if isinstance(vals, str) else "".join(vals) manager = ToolManager() @@ -204,7 +297,7 @@ class Shrimp(BaseModel): shrimp: list[Shrimp] x: None - def name_shrimp(tank: MyShrimpTank, ctx: Context) -> list[str]: + def name_shrimp(tank: MyShrimpTank, ctx: Context[ServerSessionT, None]) -> list[str]: # pragma: no cover return [x.name for x in tank.shrimp] manager = ToolManager() @@ -224,7 +317,7 @@ def name_shrimp(tank: MyShrimpTank, ctx: Context) -> list[str]: class TestToolSchema: @pytest.mark.anyio async def test_context_arg_excluded_from_schema(self): - def something(a: int, ctx: Context) -> int: + def something(a: int, ctx: Context[ServerSessionT, None]) -> int: # pragma: no cover return a manager = ToolManager() @@ -241,22 +334,22 @@ def test_context_parameter_detection(self): """Test that context parameters are properly detected in Tool.from_function().""" - def tool_with_context(x: int, ctx: Context) -> str: + def tool_with_context(x: int, ctx: Context[ServerSessionT, None]) -> str: # pragma: no cover return str(x) manager = ToolManager() tool = manager.add_tool(tool_with_context) assert tool.context_kwarg == "ctx" - def tool_without_context(x: int) -> str: + def tool_without_context(x: int) -> str: # pragma: no cover return str(x) tool = manager.add_tool(tool_without_context) assert tool.context_kwarg is None def tool_with_parametrized_context( - x: int, ctx: Context[ServerSessionT, LifespanContextT] - ) -> str: + x: int, ctx: Context[ServerSessionT, LifespanContextT, RequestT] + ) -> str: # pragma: no cover return str(x) tool = manager.add_tool(tool_with_parametrized_context) @@ -266,7 +359,7 @@ def tool_with_parametrized_context( async def test_context_injection(self): """Test that context is properly injected during tool execution.""" - def tool_with_context(x: int, ctx: Context) -> str: + def tool_with_context(x: int, ctx: Context[ServerSessionT, None]) -> str: assert isinstance(ctx, Context) return str(x) @@ -282,7 +375,7 @@ def tool_with_context(x: int, ctx: Context) -> str: async def test_context_injection_async(self): """Test that context is properly injected in async tools.""" - async def async_tool(x: int, ctx: Context) -> str: + async def async_tool(x: int, ctx: Context[ServerSessionT, None]) -> str: # pragma: no cover assert isinstance(ctx, Context) return str(x) @@ -298,7 +391,7 @@ async def async_tool(x: int, ctx: Context) -> str: async def test_context_optional(self): """Test that context is optional when calling tools.""" - def tool_with_context(x: int, ctx: Context | None = None) -> str: + def tool_with_context(x: int, ctx: Context[ServerSessionT, None] | None = None) -> str: return str(x) manager = ToolManager() @@ -311,7 +404,7 @@ def tool_with_context(x: int, ctx: Context | None = None) -> str: async def test_context_error_handling(self): """Test error handling when context injection fails.""" - def tool_with_context(x: int, ctx: Context) -> str: + def tool_with_context(x: int, ctx: Context[ServerSessionT, None]) -> str: raise ValueError("Test error") manager = ToolManager() @@ -321,3 +414,509 @@ def tool_with_context(x: int, ctx: Context) -> str: ctx = mcp.get_context() with pytest.raises(ToolError, match="Error executing tool tool_with_context"): await manager.call_tool("tool_with_context", {"x": 42}, context=ctx) + + +class TestToolAnnotations: + def test_tool_annotations(self): + """Test that tool annotations are correctly added to tools.""" + + def read_data(path: str) -> str: # pragma: no cover + """Read data from a file.""" + return f"Data from {path}" + + annotations = ToolAnnotations( + title="File Reader", + readOnlyHint=True, + openWorldHint=False, + ) + + manager = ToolManager() + tool = manager.add_tool(read_data, annotations=annotations) + + assert tool.annotations is not None + assert tool.annotations.title == "File Reader" + assert tool.annotations.readOnlyHint is True + assert tool.annotations.openWorldHint is False + + @pytest.mark.anyio + async def test_tool_annotations_in_fastmcp(self): + """Test that tool annotations are included in MCPTool conversion.""" + + app = FastMCP() + + @app.tool(annotations=ToolAnnotations(title="Echo Tool", readOnlyHint=True)) + def echo(message: str) -> str: # pragma: no cover + """Echo a message back.""" + return message + + tools = await app.list_tools() + assert len(tools) == 1 + assert tools[0].annotations is not None + assert tools[0].annotations.title == "Echo Tool" + assert tools[0].annotations.readOnlyHint is True + + +class TestStructuredOutput: + """Test structured output functionality in tools.""" + + @pytest.mark.anyio + async def test_tool_with_basemodel_output(self): + """Test tool with BaseModel return type.""" + + class UserOutput(BaseModel): + name: str + age: int + + def get_user(user_id: int) -> UserOutput: # pragma: no cover + """Get user by ID.""" + return UserOutput(name="John", age=30) + + manager = ToolManager() + manager.add_tool(get_user) + result = await manager.call_tool("get_user", {"user_id": 1}, convert_result=True) + # don't test unstructured output here, just the structured conversion + assert len(result) == 2 and result[1] == {"name": "John", "age": 30} + + @pytest.mark.anyio + async def test_tool_with_primitive_output(self): + """Test tool with primitive return type.""" + + def double_number(n: int) -> int: # pragma: no cover + """Double a number.""" + return 10 + + manager = ToolManager() + manager.add_tool(double_number) + result = await manager.call_tool("double_number", {"n": 5}) + assert result == 10 + result = await manager.call_tool("double_number", {"n": 5}, convert_result=True) + assert isinstance(result[0][0], TextContent) and result[1] == {"result": 10} + + @pytest.mark.anyio + async def test_tool_with_typeddict_output(self): + """Test tool with TypedDict return type.""" + + class UserDict(TypedDict): + name: str + age: int + + expected_output = {"name": "Alice", "age": 25} + + def get_user_dict(user_id: int) -> UserDict: # pragma: no cover + """Get user as dict.""" + return UserDict(name="Alice", age=25) + + manager = ToolManager() + manager.add_tool(get_user_dict) + result = await manager.call_tool("get_user_dict", {"user_id": 1}) + assert result == expected_output + + @pytest.mark.anyio + async def test_tool_with_dataclass_output(self): + """Test tool with dataclass return type.""" + + @dataclass + class Person: + name: str + age: int + + expected_output = {"name": "Bob", "age": 40} + + def get_person() -> Person: # pragma: no cover + """Get a person.""" + return Person("Bob", 40) + + manager = ToolManager() + manager.add_tool(get_person) + result = await manager.call_tool("get_person", {}, convert_result=True) + # don't test unstructured output here, just the structured conversion + assert len(result) == 2 and result[1] == expected_output + + @pytest.mark.anyio + async def test_tool_with_list_output(self): + """Test tool with list return type.""" + + expected_list = [1, 2, 3, 4, 5] + expected_output = {"result": expected_list} + + def get_numbers() -> list[int]: # pragma: no cover + """Get a list of numbers.""" + return expected_list + + manager = ToolManager() + manager.add_tool(get_numbers) + result = await manager.call_tool("get_numbers", {}) + assert result == expected_list + result = await manager.call_tool("get_numbers", {}, convert_result=True) + assert isinstance(result[0][0], TextContent) and result[1] == expected_output + + @pytest.mark.anyio + async def test_tool_without_structured_output(self): + """Test that tools work normally when structured_output=False.""" + + def get_dict() -> dict[str, Any]: + """Get a dict.""" + return {"key": "value"} + + manager = ToolManager() + manager.add_tool(get_dict, structured_output=False) + result = await manager.call_tool("get_dict", {}) + assert isinstance(result, dict) + assert result == {"key": "value"} + + def test_tool_output_schema_property(self): + """Test that Tool.output_schema property works correctly.""" + + class UserOutput(BaseModel): + name: str + age: int + + def get_user() -> UserOutput: # pragma: no cover + return UserOutput(name="Test", age=25) + + manager = ToolManager() + tool = manager.add_tool(get_user) + + # Test that output_schema is populated + expected_schema = { + "properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}}, + "required": ["name", "age"], + "title": "UserOutput", + "type": "object", + } + assert tool.output_schema == expected_schema + + @pytest.mark.anyio + async def test_tool_with_dict_str_any_output(self): + """Test tool with dict[str, Any] return type.""" + + def get_config() -> dict[str, Any]: # pragma: no cover + """Get configuration""" + return {"debug": True, "port": 8080, "features": ["auth", "logging"]} + + manager = ToolManager() + tool = manager.add_tool(get_config) + + # Check output schema + assert tool.output_schema is not None + assert tool.output_schema["type"] == "object" + assert "properties" not in tool.output_schema # dict[str, Any] has no constraints + + # Test raw result + result = await manager.call_tool("get_config", {}) + expected = {"debug": True, "port": 8080, "features": ["auth", "logging"]} + assert result == expected + + # Test converted result + result = await manager.call_tool("get_config", {}) + assert result == expected + + @pytest.mark.anyio + async def test_tool_with_dict_str_typed_output(self): + """Test tool with dict[str, T] return type for specific T.""" + + def get_scores() -> dict[str, int]: # pragma: no cover + """Get player scores""" + return {"alice": 100, "bob": 85, "charlie": 92} + + manager = ToolManager() + tool = manager.add_tool(get_scores) + + # Check output schema + assert tool.output_schema is not None + assert tool.output_schema["type"] == "object" + assert tool.output_schema["additionalProperties"]["type"] == "integer" + + # Test raw result + result = await manager.call_tool("get_scores", {}) + expected = {"alice": 100, "bob": 85, "charlie": 92} + assert result == expected + + # Test converted result + result = await manager.call_tool("get_scores", {}) + assert result == expected + + +class TestToolMetadata: + """Test tool metadata functionality.""" + + def test_add_tool_with_metadata(self): + """Test adding a tool with metadata via ToolManager.""" + + def process_data(input_data: str) -> str: # pragma: no cover + """Process some data.""" + return f"Processed: {input_data}" + + metadata = {"ui": {"type": "form", "fields": ["input"]}, "version": "1.0"} + + manager = ToolManager() + tool = manager.add_tool(process_data, meta=metadata) + + assert tool.meta is not None + assert tool.meta == metadata + assert tool.meta["ui"]["type"] == "form" + assert tool.meta["version"] == "1.0" + + def test_add_tool_without_metadata(self): + """Test that tools without metadata have None as meta value.""" + + def simple_tool(x: int) -> int: # pragma: no cover + """Simple tool.""" + return x * 2 + + manager = ToolManager() + tool = manager.add_tool(simple_tool) + + assert tool.meta is None + + @pytest.mark.anyio + async def test_metadata_in_fastmcp_decorator(self): + """Test that metadata is correctly added via FastMCP.tool decorator.""" + + app = FastMCP() + + metadata = {"client": {"ui_component": "file_picker"}, "priority": "high"} + + @app.tool(meta=metadata) + def upload_file(filename: str) -> str: # pragma: no cover + """Upload a file.""" + return f"Uploaded: {filename}" + + # Get the tool from the tool manager + tool = app._tool_manager.get_tool("upload_file") + assert tool is not None + assert tool.meta is not None + assert tool.meta == metadata + assert tool.meta["client"]["ui_component"] == "file_picker" + assert tool.meta["priority"] == "high" + + @pytest.mark.anyio + async def test_metadata_in_list_tools(self): + """Test that metadata is included in MCPTool when listing tools.""" + + app = FastMCP() + + metadata = { + "ui": {"input_type": "textarea", "rows": 5}, + "tags": ["text", "processing"], + } + + @app.tool(meta=metadata) + def analyze_text(text: str) -> dict[str, Any]: # pragma: no cover + """Analyze text content.""" + return {"length": len(text), "words": len(text.split())} + + tools = await app.list_tools() + assert len(tools) == 1 + assert tools[0].meta is not None + assert tools[0].meta == metadata + + @pytest.mark.anyio + async def test_multiple_tools_with_different_metadata(self): + """Test multiple tools with different metadata values.""" + + app = FastMCP() + + metadata1 = {"ui": "form", "version": 1} + metadata2 = {"ui": "picker", "experimental": True} + + @app.tool(meta=metadata1) + def tool1(x: int) -> int: # pragma: no cover + """First tool.""" + return x + + @app.tool(meta=metadata2) + def tool2(y: str) -> str: # pragma: no cover + """Second tool.""" + return y + + @app.tool() + def tool3(z: bool) -> bool: # pragma: no cover + """Third tool without metadata.""" + return z + + tools = await app.list_tools() + assert len(tools) == 3 + + # Find tools by name and check metadata + tools_by_name = {t.name: t for t in tools} + + assert tools_by_name["tool1"].meta == metadata1 + assert tools_by_name["tool2"].meta == metadata2 + assert tools_by_name["tool3"].meta is None + + def test_metadata_with_complex_structure(self): + """Test metadata with complex nested structures.""" + + def complex_tool(data: str) -> str: # pragma: no cover + """Tool with complex metadata.""" + return data + + metadata = { + "ui": { + "components": [ + {"type": "input", "name": "field1", "validation": {"required": True, "minLength": 5}}, + {"type": "select", "name": "field2", "options": ["a", "b", "c"]}, + ], + "layout": {"columns": 2, "responsive": True}, + }, + "permissions": ["read", "write"], + "tags": ["data-processing", "user-input"], + "version": 2, + } + + manager = ToolManager() + tool = manager.add_tool(complex_tool, meta=metadata) + + assert tool.meta is not None + assert tool.meta["ui"]["components"][0]["validation"]["minLength"] == 5 + assert tool.meta["ui"]["layout"]["columns"] == 2 + assert "read" in tool.meta["permissions"] + assert "data-processing" in tool.meta["tags"] + + def test_metadata_empty_dict(self): + """Test that empty dict metadata is preserved.""" + + def tool_with_empty_meta(x: int) -> int: # pragma: no cover + """Tool with empty metadata.""" + return x + + manager = ToolManager() + tool = manager.add_tool(tool_with_empty_meta, meta={}) + + assert tool.meta is not None + assert tool.meta == {} + + @pytest.mark.anyio + async def test_metadata_with_annotations(self): + """Test that metadata and annotations can coexist.""" + + app = FastMCP() + + metadata = {"custom": "value"} + annotations = ToolAnnotations(title="Combined Tool", readOnlyHint=True) + + @app.tool(meta=metadata, annotations=annotations) + def combined_tool(data: str) -> str: # pragma: no cover + """Tool with both metadata and annotations.""" + return data + + tools = await app.list_tools() + assert len(tools) == 1 + assert tools[0].meta == metadata + assert tools[0].annotations is not None + assert tools[0].annotations.title == "Combined Tool" + assert tools[0].annotations.readOnlyHint is True + + +class TestRemoveTools: + """Test tool removal functionality in the tool manager.""" + + def test_remove_existing_tool(self): + """Test removing an existing tool.""" + + def add(a: int, b: int) -> int: # pragma: no cover + """Add two numbers.""" + return a + b + + manager = ToolManager() + manager.add_tool(add) + + # Verify tool exists + assert manager.get_tool("add") is not None + assert len(manager.list_tools()) == 1 + + # Remove the tool - should not raise any exception + manager.remove_tool("add") + + # Verify tool is removed + assert manager.get_tool("add") is None + assert len(manager.list_tools()) == 0 + + def test_remove_nonexistent_tool(self): + """Test removing a non-existent tool raises ToolError.""" + manager = ToolManager() + + with pytest.raises(ToolError, match="Unknown tool: nonexistent"): + manager.remove_tool("nonexistent") + + def test_remove_tool_from_multiple_tools(self): + """Test removing one tool when multiple tools exist.""" + + def add(a: int, b: int) -> int: # pragma: no cover + """Add two numbers.""" + return a + b + + def multiply(a: int, b: int) -> int: # pragma: no cover + """Multiply two numbers.""" + return a * b + + def divide(a: int, b: int) -> float: # pragma: no cover + """Divide two numbers.""" + return a / b + + manager = ToolManager() + manager.add_tool(add) + manager.add_tool(multiply) + manager.add_tool(divide) + + # Verify all tools exist + assert len(manager.list_tools()) == 3 + assert manager.get_tool("add") is not None + assert manager.get_tool("multiply") is not None + assert manager.get_tool("divide") is not None + + # Remove middle tool + manager.remove_tool("multiply") + + # Verify only multiply is removed + assert len(manager.list_tools()) == 2 + assert manager.get_tool("add") is not None + assert manager.get_tool("multiply") is None + assert manager.get_tool("divide") is not None + + @pytest.mark.anyio + async def test_call_removed_tool_raises_error(self): + """Test that calling a removed tool raises ToolError.""" + + def greet(name: str) -> str: # pragma: no cover + """Greet someone.""" + return f"Hello, {name}!" + + manager = ToolManager() + manager.add_tool(greet) + + # Verify tool works before removal + result = await manager.call_tool("greet", {"name": "World"}) + assert result == "Hello, World!" + + # Remove the tool + manager.remove_tool("greet") + + # Verify calling removed tool raises error + with pytest.raises(ToolError, match="Unknown tool: greet"): + await manager.call_tool("greet", {"name": "World"}) + + def test_remove_tool_case_sensitive(self): + """Test that tool removal is case-sensitive.""" + + def test_func() -> str: # pragma: no cover + """Test function.""" + return "test" + + manager = ToolManager() + manager.add_tool(test_func) + + # Verify tool exists + assert manager.get_tool("test_func") is not None + + # Try to remove with different case - should raise ToolError + with pytest.raises(ToolError, match="Unknown tool: Test_Func"): + manager.remove_tool("Test_Func") + + # Verify original tool still exists + assert manager.get_tool("test_func") is not None + + # Remove with correct case + manager.remove_tool("test_func") + assert manager.get_tool("test_func") is None diff --git a/tests/server/fastmcp/test_url_elicitation.py b/tests/server/fastmcp/test_url_elicitation.py new file mode 100644 index 0000000000..a4d3b2e643 --- /dev/null +++ b/tests/server/fastmcp/test_url_elicitation.py @@ -0,0 +1,394 @@ +"""Test URL mode elicitation feature (SEP 1036).""" + +import anyio +import pytest + +from mcp import types +from mcp.client.session import ClientSession +from mcp.server.elicitation import CancelledElicitation, DeclinedElicitation +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +from mcp.shared.context import RequestContext +from mcp.shared.memory import create_connected_server_and_client_session +from mcp.types import ElicitRequestParams, ElicitResult, TextContent + + +@pytest.mark.anyio +async def test_url_elicitation_accept(): + """Test URL mode elicitation with user acceptance.""" + mcp = FastMCP(name="URLElicitationServer") + + @mcp.tool(description="A tool that uses URL elicitation") + async def request_api_key(ctx: Context[ServerSession, None]) -> str: + result = await ctx.session.elicit_url( + message="Please provide your API key to continue.", + url="https://example.com/api_key_setup", + elicitation_id="test-elicitation-001", + ) + # Test only checks accept path + return f"User {result.action}" + + # Create elicitation callback that accepts URL mode + async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + assert params.mode == "url" + assert params.url == "https://example.com/api_key_setup" + assert params.elicitationId == "test-elicitation-001" + assert params.message == "Please provide your API key to continue." + return ElicitResult(action="accept") + + async with create_connected_server_and_client_session( + mcp._mcp_server, elicitation_callback=elicitation_callback + ) as client_session: + await client_session.initialize() + + result = await client_session.call_tool("request_api_key", {}) + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "User accept" + + +@pytest.mark.anyio +async def test_url_elicitation_decline(): + """Test URL mode elicitation with user declining.""" + mcp = FastMCP(name="URLElicitationDeclineServer") + + @mcp.tool(description="A tool that uses URL elicitation") + async def oauth_flow(ctx: Context[ServerSession, None]) -> str: + result = await ctx.session.elicit_url( + message="Authorize access to your files.", + url="https://example.com/oauth/authorize", + elicitation_id="oauth-001", + ) + # Test only checks decline path + return f"User {result.action} authorization" + + async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + assert params.mode == "url" + return ElicitResult(action="decline") + + async with create_connected_server_and_client_session( + mcp._mcp_server, elicitation_callback=elicitation_callback + ) as client_session: + await client_session.initialize() + + result = await client_session.call_tool("oauth_flow", {}) + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "User decline authorization" + + +@pytest.mark.anyio +async def test_url_elicitation_cancel(): + """Test URL mode elicitation with user cancelling.""" + mcp = FastMCP(name="URLElicitationCancelServer") + + @mcp.tool(description="A tool that uses URL elicitation") + async def payment_flow(ctx: Context[ServerSession, None]) -> str: + result = await ctx.session.elicit_url( + message="Complete payment to proceed.", + url="https://example.com/payment", + elicitation_id="payment-001", + ) + # Test only checks cancel path + return f"User {result.action} payment" + + async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + assert params.mode == "url" + return ElicitResult(action="cancel") + + async with create_connected_server_and_client_session( + mcp._mcp_server, elicitation_callback=elicitation_callback + ) as client_session: + await client_session.initialize() + + result = await client_session.call_tool("payment_flow", {}) + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "User cancel payment" + + +@pytest.mark.anyio +async def test_url_elicitation_helper_function(): + """Test the elicit_url helper function.""" + from mcp.server.elicitation import elicit_url + + mcp = FastMCP(name="URLElicitationHelperServer") + + @mcp.tool(description="Tool using elicit_url helper") + async def setup_credentials(ctx: Context[ServerSession, None]) -> str: + result = await elicit_url( + session=ctx.session, + message="Set up your credentials", + url="https://example.com/setup", + elicitation_id="setup-001", + ) + # Test only checks accept path - return the type name + return type(result).__name__ + + async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + return ElicitResult(action="accept") + + async with create_connected_server_and_client_session( + mcp._mcp_server, elicitation_callback=elicitation_callback + ) as client_session: + await client_session.initialize() + + result = await client_session.call_tool("setup_credentials", {}) + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "AcceptedUrlElicitation" + + +@pytest.mark.anyio +async def test_url_no_content_in_response(): + """Test that URL mode elicitation responses don't include content field.""" + mcp = FastMCP(name="URLContentCheckServer") + + @mcp.tool(description="Check URL response format") + async def check_url_response(ctx: Context[ServerSession, None]) -> str: + result = await ctx.session.elicit_url( + message="Test message", + url="https://example.com/test", + elicitation_id="test-001", + ) + + # URL mode responses should not have content + assert result.content is None + return f"Action: {result.action}, Content: {result.content}" + + async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + # Verify that this is URL mode + assert params.mode == "url" + assert isinstance(params, types.ElicitRequestURLParams) + # URL params have url and elicitationId, not requestedSchema + assert params.url == "https://example.com/test" + assert params.elicitationId == "test-001" + # Return without content - this is correct for URL mode + return ElicitResult(action="accept") + + async with create_connected_server_and_client_session( + mcp._mcp_server, elicitation_callback=elicitation_callback + ) as client_session: + await client_session.initialize() + + result = await client_session.call_tool("check_url_response", {}) + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert "Content: None" in result.content[0].text + + +@pytest.mark.anyio +async def test_form_mode_still_works(): + """Ensure form mode elicitation still works after SEP 1036.""" + from pydantic import BaseModel, Field + + mcp = FastMCP(name="FormModeBackwardCompatServer") + + class NameSchema(BaseModel): + name: str = Field(description="Your name") + + @mcp.tool(description="Test form mode") + async def ask_name(ctx: Context[ServerSession, None]) -> str: + result = await ctx.elicit(message="What is your name?", schema=NameSchema) + # Test only checks accept path with data + assert result.action == "accept" + assert result.data is not None + return f"Hello, {result.data.name}!" + + async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + # Verify form mode parameters + assert params.mode == "form" + assert isinstance(params, types.ElicitRequestFormParams) + # Form params have requestedSchema, not url/elicitationId + assert params.requestedSchema is not None + return ElicitResult(action="accept", content={"name": "Alice"}) + + async with create_connected_server_and_client_session( + mcp._mcp_server, elicitation_callback=elicitation_callback + ) as client_session: + await client_session.initialize() + + result = await client_session.call_tool("ask_name", {}) + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello, Alice!" + + +@pytest.mark.anyio +async def test_elicit_complete_notification(): + """Test that elicitation completion notifications can be sent and received.""" + mcp = FastMCP(name="ElicitCompleteServer") + + # Track if the notification was sent + notification_sent = False + + @mcp.tool(description="Tool that sends completion notification") + async def trigger_elicitation(ctx: Context[ServerSession, None]) -> str: + nonlocal notification_sent + + # Simulate an async operation (e.g., user completing auth in browser) + elicitation_id = "complete-test-001" + + # Send completion notification + await ctx.session.send_elicit_complete(elicitation_id) + notification_sent = True + + return "Elicitation completed" + + async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + return ElicitResult(action="accept") # pragma: no cover + + async with create_connected_server_and_client_session( + mcp._mcp_server, elicitation_callback=elicitation_callback + ) as client_session: + await client_session.initialize() + + result = await client_session.call_tool("trigger_elicitation", {}) + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Elicitation completed" + + # Give time for notification to be processed + await anyio.sleep(0.1) + + # Verify the notification was sent + assert notification_sent + + +@pytest.mark.anyio +async def test_url_elicitation_required_error_code(): + """Test that the URL_ELICITATION_REQUIRED error code is correct.""" + # Verify the error code matches the specification (SEP 1036) + assert types.URL_ELICITATION_REQUIRED == -32042, ( + "URL_ELICITATION_REQUIRED error code must be -32042 per SEP 1036 specification" + ) + + +@pytest.mark.anyio +async def test_elicit_url_typed_results(): + """Test that elicit_url returns properly typed result objects.""" + from mcp.server.elicitation import elicit_url + + mcp = FastMCP(name="TypedResultsServer") + + @mcp.tool(description="Test declined result") + async def test_decline(ctx: Context[ServerSession, None]) -> str: + result = await elicit_url( + session=ctx.session, + message="Test decline", + url="https://example.com/decline", + elicitation_id="decline-001", + ) + + if isinstance(result, DeclinedElicitation): + return "Declined" + return "Not declined" # pragma: no cover + + @mcp.tool(description="Test cancelled result") + async def test_cancel(ctx: Context[ServerSession, None]) -> str: + result = await elicit_url( + session=ctx.session, + message="Test cancel", + url="https://example.com/cancel", + elicitation_id="cancel-001", + ) + + if isinstance(result, CancelledElicitation): + return "Cancelled" + return "Not cancelled" # pragma: no cover + + # Test declined result + async def decline_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + return ElicitResult(action="decline") + + async with create_connected_server_and_client_session( + mcp._mcp_server, elicitation_callback=decline_callback + ) as client_session: + await client_session.initialize() + + result = await client_session.call_tool("test_decline", {}) + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Declined" + + # Test cancelled result + async def cancel_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + return ElicitResult(action="cancel") + + async with create_connected_server_and_client_session( + mcp._mcp_server, elicitation_callback=cancel_callback + ) as client_session: + await client_session.initialize() + + result = await client_session.call_tool("test_cancel", {}) + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Cancelled" + + +@pytest.mark.anyio +async def test_deprecated_elicit_method(): + """Test the deprecated elicit() method for backward compatibility.""" + from pydantic import BaseModel, Field + + mcp = FastMCP(name="DeprecatedElicitServer") + + class EmailSchema(BaseModel): + email: str = Field(description="Email address") + + @mcp.tool(description="Test deprecated elicit method") + async def use_deprecated_elicit(ctx: Context[ServerSession, None]) -> str: + # Use the deprecated elicit() method which should call elicit_form() + result = await ctx.session.elicit( + message="Enter your email", + requestedSchema=EmailSchema.model_json_schema(), + ) + + if result.action == "accept" and result.content: + return f"Email: {result.content.get('email', 'none')}" + return "No email provided" # pragma: no cover + + async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + # Verify this is form mode + assert params.mode == "form" + assert params.requestedSchema is not None + return ElicitResult(action="accept", content={"email": "test@example.com"}) + + async with create_connected_server_and_client_session( + mcp._mcp_server, elicitation_callback=elicitation_callback + ) as client_session: + await client_session.initialize() + + result = await client_session.call_tool("use_deprecated_elicit", {}) + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Email: test@example.com" + + +@pytest.mark.anyio +async def test_ctx_elicit_url_convenience_method(): + """Test the ctx.elicit_url() convenience method (vs ctx.session.elicit_url()).""" + mcp = FastMCP(name="CtxElicitUrlServer") + + @mcp.tool(description="A tool that uses ctx.elicit_url() directly") + async def direct_elicit_url(ctx: Context[ServerSession, None]) -> str: + # Use ctx.elicit_url() directly instead of ctx.session.elicit_url() + result = await ctx.elicit_url( + message="Test the convenience method", + url="https://example.com/test", + elicitation_id="ctx-test-001", + ) + return f"Result: {result.action}" + + async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + assert params.mode == "url" + assert params.elicitationId == "ctx-test-001" + return ElicitResult(action="accept") + + async with create_connected_server_and_client_session( + mcp._mcp_server, elicitation_callback=elicitation_callback + ) as client_session: + await client_session.initialize() + result = await client_session.call_tool("direct_elicit_url", {}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Result: accept" diff --git a/tests/server/fastmcp/test_url_elicitation_error_throw.py b/tests/server/fastmcp/test_url_elicitation_error_throw.py new file mode 100644 index 0000000000..2d7eda4ab4 --- /dev/null +++ b/tests/server/fastmcp/test_url_elicitation_error_throw.py @@ -0,0 +1,113 @@ +"""Test that UrlElicitationRequiredError is properly propagated as MCP error.""" + +import pytest + +from mcp import types +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +from mcp.shared.exceptions import McpError, UrlElicitationRequiredError +from mcp.shared.memory import create_connected_server_and_client_session + + +@pytest.mark.anyio +async def test_url_elicitation_error_thrown_from_tool(): + """Test that UrlElicitationRequiredError raised from a tool is received as McpError by client.""" + mcp = FastMCP(name="UrlElicitationErrorServer") + + @mcp.tool(description="A tool that raises UrlElicitationRequiredError") + async def connect_service(service_name: str, ctx: Context[ServerSession, None]) -> str: + # This tool cannot proceed without authorization + raise UrlElicitationRequiredError( + [ + types.ElicitRequestURLParams( + mode="url", + message=f"Authorization required to connect to {service_name}", + url=f"https://{service_name}.example.com/oauth/authorize", + elicitationId=f"{service_name}-auth-001", + ) + ] + ) + + async with create_connected_server_and_client_session(mcp._mcp_server) as client_session: + await client_session.initialize() + + # Call the tool - it should raise McpError with URL_ELICITATION_REQUIRED code + with pytest.raises(McpError) as exc_info: + await client_session.call_tool("connect_service", {"service_name": "github"}) + + # Verify the error details + error = exc_info.value.error + assert error.code == types.URL_ELICITATION_REQUIRED + assert error.message == "URL elicitation required" + + # Verify the error data contains elicitations + assert error.data is not None + assert "elicitations" in error.data + elicitations = error.data["elicitations"] + assert len(elicitations) == 1 + assert elicitations[0]["mode"] == "url" + assert elicitations[0]["url"] == "https://github.example.com/oauth/authorize" + assert elicitations[0]["elicitationId"] == "github-auth-001" + + +@pytest.mark.anyio +async def test_url_elicitation_error_from_error(): + """Test that client can reconstruct UrlElicitationRequiredError from McpError.""" + mcp = FastMCP(name="UrlElicitationErrorServer") + + @mcp.tool(description="A tool that raises UrlElicitationRequiredError with multiple elicitations") + async def multi_auth(ctx: Context[ServerSession, None]) -> str: + raise UrlElicitationRequiredError( + [ + types.ElicitRequestURLParams( + mode="url", + message="GitHub authorization required", + url="https://github.example.com/oauth", + elicitationId="github-auth", + ), + types.ElicitRequestURLParams( + mode="url", + message="Google Drive authorization required", + url="https://drive.google.com/oauth", + elicitationId="gdrive-auth", + ), + ] + ) + + async with create_connected_server_and_client_session(mcp._mcp_server) as client_session: + await client_session.initialize() + + # Call the tool and catch the error + with pytest.raises(McpError) as exc_info: + await client_session.call_tool("multi_auth", {}) + + # Reconstruct the typed error + mcp_error = exc_info.value + assert mcp_error.error.code == types.URL_ELICITATION_REQUIRED + + url_error = UrlElicitationRequiredError.from_error(mcp_error.error) + + # Verify the reconstructed error has both elicitations + assert len(url_error.elicitations) == 2 + assert url_error.elicitations[0].elicitationId == "github-auth" + assert url_error.elicitations[1].elicitationId == "gdrive-auth" + + +@pytest.mark.anyio +async def test_normal_exceptions_still_return_error_result(): + """Test that normal exceptions still return CallToolResult with isError=True.""" + mcp = FastMCP(name="NormalErrorServer") + + @mcp.tool(description="A tool that raises a normal exception") + async def failing_tool(ctx: Context[ServerSession, None]) -> str: + raise ValueError("Something went wrong") + + async with create_connected_server_and_client_session(mcp._mcp_server) as client_session: + await client_session.initialize() + + # Normal exceptions should be returned as error results, not McpError + result = await client_session.call_tool("failing_tool", {}) + assert result.isError is True + assert len(result.content) == 1 + assert isinstance(result.content[0], types.TextContent) + assert "Something went wrong" in result.content[0].text diff --git a/tests/server/lowlevel/__init__.py b/tests/server/lowlevel/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/server/lowlevel/test_func_inspection.py b/tests/server/lowlevel/test_func_inspection.py new file mode 100644 index 0000000000..9cb2b561ac --- /dev/null +++ b/tests/server/lowlevel/test_func_inspection.py @@ -0,0 +1,292 @@ +"""Unit tests for func_inspection module. + +Tests the create_call_wrapper function which determines how to call handler functions +with different parameter signatures and type hints. +""" + +from typing import Any, Generic, TypeVar + +import pytest + +from mcp.server.lowlevel.func_inspection import create_call_wrapper +from mcp.types import ListPromptsRequest, ListResourcesRequest, ListToolsRequest, PaginatedRequestParams + +T = TypeVar("T") + + +@pytest.mark.anyio +async def test_no_params_returns_deprecated_wrapper() -> None: + """Test: def foo() - should call without request.""" + called_without_request = False + + async def handler() -> list[str]: + nonlocal called_without_request + called_without_request = True + return ["test"] + + wrapper = create_call_wrapper(handler, ListPromptsRequest) + + # Wrapper should call handler without passing request + request = ListPromptsRequest(method="prompts/list", params=None) + result = await wrapper(request) + assert called_without_request is True + assert result == ["test"] + + +@pytest.mark.anyio +async def test_param_with_default_returns_deprecated_wrapper() -> None: + """Test: def foo(thing: int = 1) - should call without request.""" + called_without_request = False + + async def handler(thing: int = 1) -> list[str]: + nonlocal called_without_request + called_without_request = True + return [f"test-{thing}"] + + wrapper = create_call_wrapper(handler, ListPromptsRequest) + + # Wrapper should call handler without passing request (uses default value) + request = ListPromptsRequest(method="prompts/list", params=None) + result = await wrapper(request) + assert called_without_request is True + assert result == ["test-1"] + + +@pytest.mark.anyio +async def test_typed_request_param_passes_request() -> None: + """Test: def foo(req: ListPromptsRequest) - should pass request through.""" + received_request = None + + async def handler(req: ListPromptsRequest) -> list[str]: + nonlocal received_request + received_request = req + return ["test"] + + wrapper = create_call_wrapper(handler, ListPromptsRequest) + + # Wrapper should pass request to handler + request = ListPromptsRequest(method="prompts/list", params=PaginatedRequestParams(cursor="test-cursor")) + await wrapper(request) + + assert received_request is not None + assert received_request is request + params = getattr(received_request, "params", None) + assert params is not None + assert params.cursor == "test-cursor" + + +@pytest.mark.anyio +async def test_typed_request_with_default_param_passes_request() -> None: + """Test: def foo(req: ListPromptsRequest, thing: int = 1) - should pass request through.""" + received_request = None + received_thing = None + + async def handler(req: ListPromptsRequest, thing: int = 1) -> list[str]: + nonlocal received_request, received_thing + received_request = req + received_thing = thing + return ["test"] + + wrapper = create_call_wrapper(handler, ListPromptsRequest) + + # Wrapper should pass request to handler + request = ListPromptsRequest(method="prompts/list", params=None) + await wrapper(request) + + assert received_request is request + assert received_thing == 1 # default value + + +@pytest.mark.anyio +async def test_optional_typed_request_with_default_none_is_deprecated() -> None: + """Test: def foo(thing: int = 1, req: ListPromptsRequest | None = None) - old style.""" + called_without_request = False + + async def handler(thing: int = 1, req: ListPromptsRequest | None = None) -> list[str]: + nonlocal called_without_request + called_without_request = True + return ["test"] + + wrapper = create_call_wrapper(handler, ListPromptsRequest) + + # Wrapper should call handler without passing request + request = ListPromptsRequest(method="prompts/list", params=None) + result = await wrapper(request) + assert called_without_request is True + assert result == ["test"] + + +@pytest.mark.anyio +async def test_untyped_request_param_is_deprecated() -> None: + """Test: def foo(req) - should call without request.""" + called = False + + async def handler(req): # type: ignore[no-untyped-def] # pyright: ignore[reportMissingParameterType] # pragma: no cover + nonlocal called + called = True + return ["test"] + + wrapper = create_call_wrapper(handler, ListPromptsRequest) # pyright: ignore[reportUnknownArgumentType] + + # Wrapper should call handler without passing request, which will fail because req is required + request = ListPromptsRequest(method="prompts/list", params=None) + # This will raise TypeError because handler expects 'req' but wrapper doesn't provide it + with pytest.raises(TypeError, match="missing 1 required positional argument"): + await wrapper(request) + + +@pytest.mark.anyio +async def test_any_typed_request_param_is_deprecated() -> None: + """Test: def foo(req: Any) - should call without request.""" + + async def handler(req: Any) -> list[str]: # pragma: no cover + return ["test"] + + wrapper = create_call_wrapper(handler, ListPromptsRequest) + + # Wrapper should call handler without passing request, which will fail because req is required + request = ListPromptsRequest(method="prompts/list", params=None) + # This will raise TypeError because handler expects 'req' but wrapper doesn't provide it + with pytest.raises(TypeError, match="missing 1 required positional argument"): + await wrapper(request) + + +@pytest.mark.anyio +async def test_generic_typed_request_param_is_deprecated() -> None: + """Test: def foo(req: Generic[T]) - should call without request.""" + + async def handler(req: Generic[T]) -> list[str]: # pyright: ignore[reportGeneralTypeIssues] # pragma: no cover + return ["test"] + + wrapper = create_call_wrapper(handler, ListPromptsRequest) + + # Wrapper should call handler without passing request, which will fail because req is required + request = ListPromptsRequest(method="prompts/list", params=None) + # This will raise TypeError because handler expects 'req' but wrapper doesn't provide it + with pytest.raises(TypeError, match="missing 1 required positional argument"): + await wrapper(request) + + +@pytest.mark.anyio +async def test_wrong_typed_request_param_is_deprecated() -> None: + """Test: def foo(req: str) - should call without request.""" + + async def handler(req: str) -> list[str]: # pragma: no cover + return ["test"] + + wrapper = create_call_wrapper(handler, ListPromptsRequest) + + # Wrapper should call handler without passing request, which will fail because req is required + request = ListPromptsRequest(method="prompts/list", params=None) + # This will raise TypeError because handler expects 'req' but wrapper doesn't provide it + with pytest.raises(TypeError, match="missing 1 required positional argument"): + await wrapper(request) + + +@pytest.mark.anyio +async def test_required_param_before_typed_request_attempts_to_pass() -> None: + """Test: def foo(thing: int, req: ListPromptsRequest) - attempts to pass request (will fail at runtime).""" + received_request = None + + async def handler(thing: int, req: ListPromptsRequest) -> list[str]: # pragma: no cover + nonlocal received_request + received_request = req + return ["test"] + + wrapper = create_call_wrapper(handler, ListPromptsRequest) + + # Wrapper will attempt to pass request, but it will fail at runtime + # because 'thing' is required and has no default + request = ListPromptsRequest(method="prompts/list", params=None) + + # This will raise TypeError because 'thing' is missing + with pytest.raises(TypeError, match="missing 1 required positional argument: 'thing'"): + await wrapper(request) + + +@pytest.mark.anyio +async def test_positional_only_param_with_correct_type() -> None: + """Test: def foo(req: ListPromptsRequest, /) - should pass request through.""" + received_request = None + + async def handler(req: ListPromptsRequest, /) -> list[str]: + nonlocal received_request + received_request = req + return ["test"] + + wrapper = create_call_wrapper(handler, ListPromptsRequest) + + # Wrapper should pass request to handler + request = ListPromptsRequest(method="prompts/list", params=None) + await wrapper(request) + + assert received_request is request + + +@pytest.mark.anyio +async def test_keyword_only_param_with_correct_type() -> None: + """Test: def foo(*, req: ListPromptsRequest) - should pass request through.""" + received_request = None + + async def handler(*, req: ListPromptsRequest) -> list[str]: + nonlocal received_request + received_request = req + return ["test"] + + wrapper = create_call_wrapper(handler, ListPromptsRequest) + + # Wrapper should pass request to handler with keyword argument + request = ListPromptsRequest(method="prompts/list", params=None) + await wrapper(request) + + assert received_request is request + + +@pytest.mark.anyio +async def test_different_request_types() -> None: + """Test that wrapper works with different request types.""" + # Test with ListResourcesRequest + received_request = None + + async def handler(req: ListResourcesRequest) -> list[str]: + nonlocal received_request + received_request = req + return ["test"] + + wrapper = create_call_wrapper(handler, ListResourcesRequest) + + request = ListResourcesRequest(method="resources/list", params=None) + await wrapper(request) + + assert received_request is request + + # Test with ListToolsRequest + received_request = None + + async def handler2(req: ListToolsRequest) -> list[str]: + nonlocal received_request + received_request = req + return ["test"] + + wrapper2 = create_call_wrapper(handler2, ListToolsRequest) + + request2 = ListToolsRequest(method="tools/list", params=None) + await wrapper2(request2) + + assert received_request is request2 + + +@pytest.mark.anyio +async def test_mixed_params_with_typed_request() -> None: + """Test: def foo(a: str, req: ListPromptsRequest, b: int = 5) - attempts to pass request.""" + + async def handler(a: str, req: ListPromptsRequest, b: int = 5) -> list[str]: # pragma: no cover + return ["test"] + + wrapper = create_call_wrapper(handler, ListPromptsRequest) + + # Will fail at runtime due to missing 'a' + request = ListPromptsRequest(method="prompts/list", params=None) + + with pytest.raises(TypeError, match="missing 1 required positional argument: 'a'"): + await wrapper(request) diff --git a/tests/server/lowlevel/test_server_listing.py b/tests/server/lowlevel/test_server_listing.py new file mode 100644 index 0000000000..23ac7e4519 --- /dev/null +++ b/tests/server/lowlevel/test_server_listing.py @@ -0,0 +1,182 @@ +"""Basic tests for list_prompts, list_resources, and list_tools decorators without pagination.""" + +import warnings + +import pytest +from pydantic import AnyUrl + +from mcp.server import Server +from mcp.types import ( + ListPromptsRequest, + ListPromptsResult, + ListResourcesRequest, + ListResourcesResult, + ListToolsRequest, + ListToolsResult, + Prompt, + Resource, + ServerResult, + Tool, +) + + +@pytest.mark.anyio +async def test_list_prompts_basic() -> None: + """Test basic prompt listing without pagination.""" + server = Server("test") + + test_prompts = [ + Prompt(name="prompt1", description="First prompt"), + Prompt(name="prompt2", description="Second prompt"), + ] + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + @server.list_prompts() + async def handle_list_prompts() -> list[Prompt]: + return test_prompts + + handler = server.request_handlers[ListPromptsRequest] + request = ListPromptsRequest(method="prompts/list", params=None) + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, ListPromptsResult) + assert result.root.prompts == test_prompts + + +@pytest.mark.anyio +async def test_list_resources_basic() -> None: + """Test basic resource listing without pagination.""" + server = Server("test") + + test_resources = [ + Resource(uri=AnyUrl("file:///test1.txt"), name="Test 1"), + Resource(uri=AnyUrl("file:///test2.txt"), name="Test 2"), + ] + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + @server.list_resources() + async def handle_list_resources() -> list[Resource]: + return test_resources + + handler = server.request_handlers[ListResourcesRequest] + request = ListResourcesRequest(method="resources/list", params=None) + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, ListResourcesResult) + assert result.root.resources == test_resources + + +@pytest.mark.anyio +async def test_list_tools_basic() -> None: + """Test basic tool listing without pagination.""" + server = Server("test") + + test_tools = [ + Tool( + name="tool1", + description="First tool", + inputSchema={ + "type": "object", + "properties": { + "message": {"type": "string"}, + }, + "required": ["message"], + }, + ), + Tool( + name="tool2", + description="Second tool", + inputSchema={ + "type": "object", + "properties": { + "count": {"type": "number"}, + "enabled": {"type": "boolean"}, + }, + "required": ["count"], + }, + ), + ] + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + @server.list_tools() + async def handle_list_tools() -> list[Tool]: + return test_tools + + handler = server.request_handlers[ListToolsRequest] + request = ListToolsRequest(method="tools/list", params=None) + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, ListToolsResult) + assert result.root.tools == test_tools + + +@pytest.mark.anyio +async def test_list_prompts_empty() -> None: + """Test listing with empty results.""" + server = Server("test") + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + @server.list_prompts() + async def handle_list_prompts() -> list[Prompt]: + return [] + + handler = server.request_handlers[ListPromptsRequest] + request = ListPromptsRequest(method="prompts/list", params=None) + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, ListPromptsResult) + assert result.root.prompts == [] + + +@pytest.mark.anyio +async def test_list_resources_empty() -> None: + """Test listing with empty results.""" + server = Server("test") + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + @server.list_resources() + async def handle_list_resources() -> list[Resource]: + return [] + + handler = server.request_handlers[ListResourcesRequest] + request = ListResourcesRequest(method="resources/list", params=None) + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, ListResourcesResult) + assert result.root.resources == [] + + +@pytest.mark.anyio +async def test_list_tools_empty() -> None: + """Test listing with empty results.""" + server = Server("test") + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + @server.list_tools() + async def handle_list_tools() -> list[Tool]: + return [] + + handler = server.request_handlers[ListToolsRequest] + request = ListToolsRequest(method="tools/list", params=None) + result = await handler(request) + + assert isinstance(result, ServerResult) + assert isinstance(result.root, ListToolsResult) + assert result.root.tools == [] diff --git a/tests/server/lowlevel/test_server_pagination.py b/tests/server/lowlevel/test_server_pagination.py new file mode 100644 index 0000000000..8d64dd5253 --- /dev/null +++ b/tests/server/lowlevel/test_server_pagination.py @@ -0,0 +1,111 @@ +import pytest + +from mcp.server import Server +from mcp.types import ( + ListPromptsRequest, + ListPromptsResult, + ListResourcesRequest, + ListResourcesResult, + ListToolsRequest, + ListToolsResult, + PaginatedRequestParams, + ServerResult, +) + + +@pytest.mark.anyio +async def test_list_prompts_pagination() -> None: + server = Server("test") + test_cursor = "test-cursor-123" + + # Track what request was received + received_request: ListPromptsRequest | None = None + + @server.list_prompts() + async def handle_list_prompts(request: ListPromptsRequest) -> ListPromptsResult: + nonlocal received_request + received_request = request + return ListPromptsResult(prompts=[], nextCursor="next") + + handler = server.request_handlers[ListPromptsRequest] + + # Test: No cursor provided -> handler receives request with None params + request = ListPromptsRequest(method="prompts/list", params=None) + result = await handler(request) + assert received_request is not None + assert received_request.params is None + assert isinstance(result, ServerResult) + + # Test: Cursor provided -> handler receives request with cursor in params + request_with_cursor = ListPromptsRequest(method="prompts/list", params=PaginatedRequestParams(cursor=test_cursor)) + result2 = await handler(request_with_cursor) + assert received_request is not None + assert received_request.params is not None + assert received_request.params.cursor == test_cursor + assert isinstance(result2, ServerResult) + + +@pytest.mark.anyio +async def test_list_resources_pagination() -> None: + server = Server("test") + test_cursor = "resource-cursor-456" + + # Track what request was received + received_request: ListResourcesRequest | None = None + + @server.list_resources() + async def handle_list_resources(request: ListResourcesRequest) -> ListResourcesResult: + nonlocal received_request + received_request = request + return ListResourcesResult(resources=[], nextCursor="next") + + handler = server.request_handlers[ListResourcesRequest] + + # Test: No cursor provided -> handler receives request with None params + request = ListResourcesRequest(method="resources/list", params=None) + result = await handler(request) + assert received_request is not None + assert received_request.params is None + assert isinstance(result, ServerResult) + + # Test: Cursor provided -> handler receives request with cursor in params + request_with_cursor = ListResourcesRequest( + method="resources/list", params=PaginatedRequestParams(cursor=test_cursor) + ) + result2 = await handler(request_with_cursor) + assert received_request is not None + assert received_request.params is not None + assert received_request.params.cursor == test_cursor + assert isinstance(result2, ServerResult) + + +@pytest.mark.anyio +async def test_list_tools_pagination() -> None: + server = Server("test") + test_cursor = "tools-cursor-789" + + # Track what request was received + received_request: ListToolsRequest | None = None + + @server.list_tools() + async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult: + nonlocal received_request + received_request = request + return ListToolsResult(tools=[], nextCursor="next") + + handler = server.request_handlers[ListToolsRequest] + + # Test: No cursor provided -> handler receives request with None params + request = ListToolsRequest(method="tools/list", params=None) + result = await handler(request) + assert received_request is not None + assert received_request.params is None + assert isinstance(result, ServerResult) + + # Test: Cursor provided -> handler receives request with cursor in params + request_with_cursor = ListToolsRequest(method="tools/list", params=PaginatedRequestParams(cursor=test_cursor)) + result2 = await handler(request_with_cursor) + assert received_request is not None + assert received_request.params is not None + assert received_request.params.cursor == test_cursor + assert isinstance(result2, ServerResult) diff --git a/tests/server/test_cancel_handling.py b/tests/server/test_cancel_handling.py new file mode 100644 index 0000000000..47c49bb62b --- /dev/null +++ b/tests/server/test_cancel_handling.py @@ -0,0 +1,110 @@ +"""Test that cancelled requests don't cause double responses.""" + +from typing import Any + +import anyio +import pytest + +import mcp.types as types +from mcp.server.lowlevel.server import Server +from mcp.shared.exceptions import McpError +from mcp.shared.memory import create_connected_server_and_client_session +from mcp.types import ( + CallToolRequest, + CallToolRequestParams, + CallToolResult, + CancelledNotification, + CancelledNotificationParams, + ClientNotification, + ClientRequest, + Tool, +) + + +@pytest.mark.anyio +async def test_server_remains_functional_after_cancel(): + """Verify server can handle new requests after a cancellation.""" + + server = Server("test-server") + + # Track tool calls + call_count = 0 + ev_first_call = anyio.Event() + first_request_id = None + + @server.list_tools() + async def handle_list_tools() -> list[Tool]: + return [ + Tool( + name="test_tool", + description="Tool for testing", + inputSchema={}, + ) + ] + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]: + nonlocal call_count, first_request_id + if name == "test_tool": + call_count += 1 + if call_count == 1: + first_request_id = server.request_context.request_id + ev_first_call.set() + await anyio.sleep(5) # First call is slow + return [types.TextContent(type="text", text=f"Call number: {call_count}")] + raise ValueError(f"Unknown tool: {name}") # pragma: no cover + + async with create_connected_server_and_client_session(server) as client: + # First request (will be cancelled) + async def first_request(): + try: + await client.send_request( + ClientRequest( + CallToolRequest( + params=CallToolRequestParams(name="test_tool", arguments={}), + ) + ), + CallToolResult, + ) + pytest.fail("First request should have been cancelled") # pragma: no cover + except McpError: + pass # Expected + + # Start first request + async with anyio.create_task_group() as tg: + tg.start_soon(first_request) + + # Wait for it to start + await ev_first_call.wait() + + # Cancel it + assert first_request_id is not None + await client.send_notification( + ClientNotification( + CancelledNotification( + params=CancelledNotificationParams( + requestId=first_request_id, + reason="Testing server recovery", + ), + ) + ) + ) + + # Second request (should work normally) + result = await client.send_request( + ClientRequest( + CallToolRequest( + params=CallToolRequestParams(name="test_tool", arguments={}), + ) + ), + CallToolResult, + ) + + # Verify second request completed successfully + assert len(result.content) == 1 + # Type narrowing for pyright + content = result.content[0] + assert content.type == "text" + assert isinstance(content, types.TextContent) + assert content.text == "Call number: 2" + assert call_count == 2 diff --git a/tests/server/test_completion_with_context.py b/tests/server/test_completion_with_context.py new file mode 100644 index 0000000000..eb9604791a --- /dev/null +++ b/tests/server/test_completion_with_context.py @@ -0,0 +1,182 @@ +""" +Tests for completion handler with context functionality. +""" + +from typing import Any + +import pytest + +from mcp.server.lowlevel import Server +from mcp.shared.memory import create_connected_server_and_client_session +from mcp.types import ( + Completion, + CompletionArgument, + CompletionContext, + PromptReference, + ResourceTemplateReference, +) + + +@pytest.mark.anyio +async def test_completion_handler_receives_context(): + """Test that the completion handler receives context correctly.""" + server = Server("test-server") + + # Track what the handler receives + received_args: dict[str, Any] = {} + + @server.completion() + async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, + ) -> Completion | None: + received_args["ref"] = ref + received_args["argument"] = argument + received_args["context"] = context + + # Return test completion + return Completion(values=["test-completion"], total=1, hasMore=False) + + async with create_connected_server_and_client_session(server) as client: + # Test with context + result = await client.complete( + ref=ResourceTemplateReference(type="ref/resource", uri="test://resource/{param}"), + argument={"name": "param", "value": "test"}, + context_arguments={"previous": "value"}, + ) + + # Verify handler received the context + assert received_args["context"] is not None + assert received_args["context"].arguments == {"previous": "value"} + assert result.completion.values == ["test-completion"] + + +@pytest.mark.anyio +async def test_completion_backward_compatibility(): + """Test that completion works without context (backward compatibility).""" + server = Server("test-server") + + context_was_none = False + + @server.completion() + async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, + ) -> Completion | None: + nonlocal context_was_none + context_was_none = context is None + + return Completion(values=["no-context-completion"], total=1, hasMore=False) + + async with create_connected_server_and_client_session(server) as client: + # Test without context + result = await client.complete( + ref=PromptReference(type="ref/prompt", name="test-prompt"), argument={"name": "arg", "value": "val"} + ) + + # Verify context was None + assert context_was_none + assert result.completion.values == ["no-context-completion"] + + +@pytest.mark.anyio +async def test_dependent_completion_scenario(): + """Test a real-world scenario with dependent completions.""" + server = Server("test-server") + + @server.completion() + async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, + ) -> Completion | None: + # Simulate database/table completion scenario + if isinstance(ref, ResourceTemplateReference): + if ref.uri == "db://{database}/{table}": + if argument.name == "database": + # Complete database names + return Completion(values=["users_db", "products_db", "analytics_db"], total=3, hasMore=False) + elif argument.name == "table": + # Complete table names based on selected database + if context and context.arguments: + db = context.arguments.get("database") + if db == "users_db": + return Completion(values=["users", "sessions", "permissions"], total=3, hasMore=False) + elif db == "products_db": # pragma: no cover + return Completion(values=["products", "categories", "inventory"], total=3, hasMore=False) + + return Completion(values=[], total=0, hasMore=False) # pragma: no cover + + async with create_connected_server_and_client_session(server) as client: + # First, complete database + db_result = await client.complete( + ref=ResourceTemplateReference(type="ref/resource", uri="db://{database}/{table}"), + argument={"name": "database", "value": ""}, + ) + assert "users_db" in db_result.completion.values + assert "products_db" in db_result.completion.values + + # Then complete table with database context + table_result = await client.complete( + ref=ResourceTemplateReference(type="ref/resource", uri="db://{database}/{table}"), + argument={"name": "table", "value": ""}, + context_arguments={"database": "users_db"}, + ) + assert table_result.completion.values == ["users", "sessions", "permissions"] + + # Different database gives different tables + table_result2 = await client.complete( + ref=ResourceTemplateReference(type="ref/resource", uri="db://{database}/{table}"), + argument={"name": "table", "value": ""}, + context_arguments={"database": "products_db"}, + ) + assert table_result2.completion.values == ["products", "categories", "inventory"] + + +@pytest.mark.anyio +async def test_completion_error_on_missing_context(): + """Test that server can raise error when required context is missing.""" + server = Server("test-server") + + @server.completion() + async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, + ) -> Completion | None: + if isinstance(ref, ResourceTemplateReference): + if ref.uri == "db://{database}/{table}": + if argument.name == "table": + # Check if database context is provided + if not context or not context.arguments or "database" not in context.arguments: + # Raise an error instead of returning error as completion + raise ValueError("Please select a database first to see available tables") + # Normal completion if context is provided + db = context.arguments.get("database") + if db == "test_db": # pragma: no cover + return Completion(values=["users", "orders", "products"], total=3, hasMore=False) + + return Completion(values=[], total=0, hasMore=False) # pragma: no cover + + async with create_connected_server_and_client_session(server) as client: + # Try to complete table without database context - should raise error + with pytest.raises(Exception) as exc_info: + await client.complete( + ref=ResourceTemplateReference(type="ref/resource", uri="db://{database}/{table}"), + argument={"name": "table", "value": ""}, + ) + + # Verify error message + assert "Please select a database first" in str(exc_info.value) + + # Now complete with proper context - should work normally + result_with_context = await client.complete( + ref=ResourceTemplateReference(type="ref/resource", uri="db://{database}/{table}"), + argument={"name": "table", "value": ""}, + context_arguments={"database": "test_db"}, + ) + + # Should get normal completions + assert result_with_context.completion.values == ["users", "orders", "products"] diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py index 309a44b870..9d73fd47a0 100644 --- a/tests/server/test_lifespan.py +++ b/tests/server/test_lifespan.py @@ -2,6 +2,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from typing import Any import anyio import pytest @@ -10,6 +11,8 @@ from mcp.server.fastmcp import Context, FastMCP from mcp.server.lowlevel.server import NotificationOptions, Server from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.message import SessionMessage from mcp.types import ( ClientCapabilities, Implementation, @@ -17,6 +20,8 @@ JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, + JSONRPCResponse, + TextContent, ) @@ -34,29 +39,23 @@ async def test_lifespan(server: Server) -> AsyncIterator[dict[str, bool]]: finally: context["shutdown"] = True - server = Server("test", lifespan=test_lifespan) + server = Server[dict[str, bool]]("test", lifespan=test_lifespan) # Create memory streams for testing - send_stream1, receive_stream1 = anyio.create_memory_object_stream(100) - send_stream2, receive_stream2 = anyio.create_memory_object_stream(100) + send_stream1, receive_stream1 = anyio.create_memory_object_stream[SessionMessage](100) + send_stream2, receive_stream2 = anyio.create_memory_object_stream[SessionMessage](100) # Create a tool that accesses lifespan context @server.call_tool() - async def check_lifespan(name: str, arguments: dict) -> list: + async def check_lifespan(name: str, arguments: dict[str, Any]) -> list[TextContent]: ctx = server.request_context assert isinstance(ctx.lifespan_context, dict) assert ctx.lifespan_context["started"] assert not ctx.lifespan_context["shutdown"] - return [{"type": "text", "text": "true"}] + return [TextContent(type="text", text="true")] # Run server in background task - async with ( - anyio.create_task_group() as tg, - send_stream1, - receive_stream1, - send_stream2, - receive_stream2, - ): + async with anyio.create_task_group() as tg, send_stream1, receive_stream1, send_stream2, receive_stream2: async def run_server(): await server.run( @@ -82,41 +81,51 @@ async def run_server(): clientInfo=Implementation(name="test-client", version="0.1.0"), ) await send_stream1.send( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=1, - method="initialize", - params=TypeAdapter(InitializeRequestParams).dump_python(params), + SessionMessage( + JSONRPCMessage( + root=JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="initialize", + params=TypeAdapter(InitializeRequestParams).dump_python(params), + ) ) ) ) response = await receive_stream2.receive() + response = response.message # Send initialized notification await send_stream1.send( - JSONRPCMessage( - root=JSONRPCNotification( - jsonrpc="2.0", - method="notifications/initialized", + SessionMessage( + JSONRPCMessage( + root=JSONRPCNotification( + jsonrpc="2.0", + method="notifications/initialized", + ) ) ) ) # Call the tool to verify lifespan context await send_stream1.send( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=2, - method="tools/call", - params={"name": "check_lifespan", "arguments": {}}, + SessionMessage( + JSONRPCMessage( + root=JSONRPCRequest( + jsonrpc="2.0", + id=2, + method="tools/call", + params={"name": "check_lifespan", "arguments": {}}, + ) ) ) ) # Get response and verify response = await receive_stream2.receive() + response = response.message + assert isinstance(response, JSONRPCMessage) + assert isinstance(response.root, JSONRPCResponse) assert response.root.result["content"][0]["text"] == "true" # Cancel server task @@ -128,7 +137,7 @@ async def test_fastmcp_server_lifespan(): """Test that lifespan works in FastMCP server.""" @asynccontextmanager - async def test_lifespan(server: FastMCP) -> AsyncIterator[dict]: + async def test_lifespan(server: FastMCP) -> AsyncIterator[dict[str, bool]]: """Test lifespan context that tracks startup/shutdown.""" context = {"started": False, "shutdown": False} try: @@ -140,12 +149,12 @@ async def test_lifespan(server: FastMCP) -> AsyncIterator[dict]: server = FastMCP("test", lifespan=test_lifespan) # Create memory streams for testing - send_stream1, receive_stream1 = anyio.create_memory_object_stream(100) - send_stream2, receive_stream2 = anyio.create_memory_object_stream(100) + send_stream1, receive_stream1 = anyio.create_memory_object_stream[SessionMessage](100) + send_stream2, receive_stream2 = anyio.create_memory_object_stream[SessionMessage](100) # Add a tool that checks lifespan context @server.tool() - def check_lifespan(ctx: Context) -> bool: + def check_lifespan(ctx: Context[ServerSession, None]) -> bool: """Tool that checks lifespan context.""" assert isinstance(ctx.request_context.lifespan_context, dict) assert ctx.request_context.lifespan_context["started"] @@ -178,41 +187,51 @@ async def run_server(): clientInfo=Implementation(name="test-client", version="0.1.0"), ) await send_stream1.send( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=1, - method="initialize", - params=TypeAdapter(InitializeRequestParams).dump_python(params), + SessionMessage( + JSONRPCMessage( + root=JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="initialize", + params=TypeAdapter(InitializeRequestParams).dump_python(params), + ) ) ) ) response = await receive_stream2.receive() + response = response.message # Send initialized notification await send_stream1.send( - JSONRPCMessage( - root=JSONRPCNotification( - jsonrpc="2.0", - method="notifications/initialized", + SessionMessage( + JSONRPCMessage( + root=JSONRPCNotification( + jsonrpc="2.0", + method="notifications/initialized", + ) ) ) ) # Call the tool to verify lifespan context await send_stream1.send( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=2, - method="tools/call", - params={"name": "check_lifespan", "arguments": {}}, + SessionMessage( + JSONRPCMessage( + root=JSONRPCRequest( + jsonrpc="2.0", + id=2, + method="tools/call", + params={"name": "check_lifespan", "arguments": {}}, + ) ) ) ) # Get response and verify response = await receive_stream2.receive() + response = response.message + assert isinstance(response, JSONRPCMessage) + assert isinstance(response.root, JSONRPCResponse) assert response.root.result["content"][0]["text"] == "true" # Cancel server task diff --git a/tests/server/test_lowlevel_exception_handling.py b/tests/server/test_lowlevel_exception_handling.py new file mode 100644 index 0000000000..5d4c3347f6 --- /dev/null +++ b/tests/server/test_lowlevel_exception_handling.py @@ -0,0 +1,74 @@ +from unittest.mock import AsyncMock, Mock + +import pytest + +import mcp.types as types +from mcp.server.lowlevel.server import Server +from mcp.server.session import ServerSession +from mcp.shared.session import RequestResponder + + +@pytest.mark.anyio +async def test_exception_handling_with_raise_exceptions_true(): + """Test that exceptions are re-raised when raise_exceptions=True""" + server = Server("test-server") + session = Mock(spec=ServerSession) + session.send_log_message = AsyncMock() + + test_exception = RuntimeError("Test error") + + with pytest.raises(RuntimeError, match="Test error"): + await server._handle_message(test_exception, session, {}, raise_exceptions=True) + + session.send_log_message.assert_called_once() + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "exception_class,message", + [ + (ValueError, "Test validation error"), + (RuntimeError, "Test runtime error"), + (KeyError, "Test key error"), + (Exception, "Basic error"), + ], +) +async def test_exception_handling_with_raise_exceptions_false(exception_class: type[Exception], message: str): + """Test that exceptions are logged when raise_exceptions=False""" + server = Server("test-server") + session = Mock(spec=ServerSession) + session.send_log_message = AsyncMock() + + test_exception = exception_class(message) + + await server._handle_message(test_exception, session, {}, raise_exceptions=False) + + # Should send log message + session.send_log_message.assert_called_once() + call_args = session.send_log_message.call_args + + assert call_args.kwargs["level"] == "error" + assert call_args.kwargs["data"] == "Internal Server Error" + assert call_args.kwargs["logger"] == "mcp.server.exception_handler" + + +@pytest.mark.anyio +async def test_normal_message_handling_not_affected(): + """Test that normal messages still work correctly""" + server = Server("test-server") + session = Mock(spec=ServerSession) + + # Create a mock RequestResponder + responder = Mock(spec=RequestResponder) + responder.request = types.ClientRequest(root=types.PingRequest(method="ping")) + responder.__enter__ = Mock(return_value=responder) + responder.__exit__ = Mock(return_value=None) + + # Mock the _handle_request method to avoid complex setup + server._handle_request = AsyncMock() + + # Should handle normally without any exception handling + await server._handle_message(responder, session, {}, raise_exceptions=False) + + # Verify _handle_request was called + server._handle_request.assert_called_once() diff --git a/tests/server/test_lowlevel_input_validation.py b/tests/server/test_lowlevel_input_validation.py new file mode 100644 index 0000000000..47cb57232d --- /dev/null +++ b/tests/server/test_lowlevel_input_validation.py @@ -0,0 +1,311 @@ +"""Test input schema validation for lowlevel server.""" + +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +import anyio +import pytest + +from mcp.client.session import ClientSession +from mcp.server import Server +from mcp.server.lowlevel import NotificationOptions +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder +from mcp.types import CallToolResult, ClientResult, ServerNotification, ServerRequest, TextContent, Tool + + +async def run_tool_test( + tools: list[Tool], + call_tool_handler: Callable[[str, dict[str, Any]], Awaitable[list[TextContent]]], + test_callback: Callable[[ClientSession], Awaitable[CallToolResult]], +) -> CallToolResult | None: + """Helper to run a tool test with minimal boilerplate. + + Args: + tools: List of tools to register + call_tool_handler: Handler function for tool calls + test_callback: Async function that performs the test using the client session + + Returns: + The result of the tool call + """ + server = Server("test") + result = None + + @server.list_tools() + async def list_tools(): + return tools + + @server.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: + return await call_tool_handler(name, arguments) + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + # Message handler for client + async def message_handler( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): # pragma: no cover + raise message + + # Server task + async def run_server(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) as server_session: + async with anyio.create_task_group() as tg: + + async def handle_messages(): + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, {}, False) + + tg.start_soon(handle_messages) + await anyio.sleep_forever() + + # Run the test + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=message_handler, + ) as client_session: + # Initialize the session + await client_session.initialize() + + # Run the test callback + result = await test_callback(client_session) + + # Cancel the server task + tg.cancel_scope.cancel() + + return result + + +def create_add_tool() -> Tool: + """Create a standard 'add' tool for testing.""" + return Tool( + name="add", + description="Add two numbers", + inputSchema={ + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"}, + }, + "required": ["a", "b"], + "additionalProperties": False, + }, + ) + + +@pytest.mark.anyio +async def test_valid_tool_call(): + """Test that valid arguments pass validation.""" + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: + if name == "add": + result = arguments["a"] + arguments["b"] + return [TextContent(type="text", text=f"Result: {result}")] + else: # pragma: no cover + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("add", {"a": 5, "b": 3}) + + result = await run_tool_test([create_add_tool()], call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Result: 8" + + +@pytest.mark.anyio +async def test_invalid_tool_call_missing_required(): + """Test that missing required arguments fail validation.""" + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: # pragma: no cover + # This should not be reached due to validation + raise RuntimeError("Should not reach here") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("add", {"a": 5}) # missing 'b' + + result = await run_tool_test([create_add_tool()], call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert "Input validation error" in result.content[0].text + assert "'b' is a required property" in result.content[0].text + + +@pytest.mark.anyio +async def test_invalid_tool_call_wrong_type(): + """Test that wrong argument types fail validation.""" + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: # pragma: no cover + # This should not be reached due to validation + raise RuntimeError("Should not reach here") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("add", {"a": "five", "b": 3}) # 'a' should be number + + result = await run_tool_test([create_add_tool()], call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert "Input validation error" in result.content[0].text + assert "'five' is not of type 'number'" in result.content[0].text + + +@pytest.mark.anyio +async def test_cache_refresh_on_missing_tool(): + """Test that tool cache is refreshed when tool is not found.""" + tools = [ + Tool( + name="multiply", + description="Multiply two numbers", + inputSchema={ + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + }, + "required": ["x", "y"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: + if name == "multiply": + result = arguments["x"] * arguments["y"] + return [TextContent(type="text", text=f"Result: {result}")] + else: # pragma: no cover + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + # Call tool without first listing tools (cache should be empty) + # The cache should be refreshed automatically + return await client_session.call_tool("multiply", {"x": 10, "y": 20}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results - should work because cache will be refreshed + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Result: 200" + + +@pytest.mark.anyio +async def test_enum_constraint_validation(): + """Test that enum constraints are validated.""" + tools = [ + Tool( + name="greet", + description="Greet someone", + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "title": {"type": "string", "enum": ["Mr", "Ms", "Dr"]}, + }, + "required": ["name"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: # pragma: no cover + # This should not be reached due to validation failure + raise RuntimeError("Should not reach here") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("greet", {"name": "Smith", "title": "Prof"}) # Invalid title + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert "Input validation error" in result.content[0].text + assert "'Prof' is not one of" in result.content[0].text + + +@pytest.mark.anyio +async def test_tool_not_in_list_logs_warning(caplog: pytest.LogCaptureFixture): + """Test that calling a tool not in list_tools logs a warning and skips validation.""" + tools = [ + Tool( + name="add", + description="Add two numbers", + inputSchema={ + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"}, + }, + "required": ["a", "b"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: + # This should be reached since validation is skipped for unknown tools + if name == "unknown_tool": + # Even with invalid arguments, this should execute since validation is skipped + return [TextContent(type="text", text="Unknown tool executed without validation")] + else: # pragma: no cover + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + # Call a tool that's not in the list with invalid arguments + # This should trigger the warning about validation not being performed + return await client_session.call_tool("unknown_tool", {"invalid": "args"}) + + with caplog.at_level(logging.WARNING): + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results - should succeed because validation is skipped for unknown tools + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Unknown tool executed without validation" + + # Verify warning was logged + assert any( + "Tool 'unknown_tool' not listed, no validation will be performed" in record.message for record in caplog.records + ) diff --git a/tests/server/test_lowlevel_output_validation.py b/tests/server/test_lowlevel_output_validation.py new file mode 100644 index 0000000000..f735445212 --- /dev/null +++ b/tests/server/test_lowlevel_output_validation.py @@ -0,0 +1,476 @@ +"""Test output schema validation for lowlevel server.""" + +import json +from collections.abc import Awaitable, Callable +from typing import Any + +import anyio +import pytest + +from mcp.client.session import ClientSession +from mcp.server import Server +from mcp.server.lowlevel import NotificationOptions +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder +from mcp.types import CallToolResult, ClientResult, ServerNotification, ServerRequest, TextContent, Tool + + +async def run_tool_test( + tools: list[Tool], + call_tool_handler: Callable[[str, dict[str, Any]], Awaitable[Any]], + test_callback: Callable[[ClientSession], Awaitable[CallToolResult]], +) -> CallToolResult | None: + """Helper to run a tool test with minimal boilerplate. + + Args: + tools: List of tools to register + call_tool_handler: Handler function for tool calls + test_callback: Async function that performs the test using the client session + + Returns: + The result of the tool call + """ + server = Server("test") + + result = None + + @server.list_tools() + async def list_tools(): + return tools + + @server.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]): + return await call_tool_handler(name, arguments) + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + # Message handler for client + async def message_handler( # pragma: no cover + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): + raise message + + # Server task + async def run_server(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) as server_session: + async with anyio.create_task_group() as tg: + + async def handle_messages(): + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, {}, False) + + tg.start_soon(handle_messages) + await anyio.sleep_forever() + + # Run the test + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=message_handler, + ) as client_session: + # Initialize the session + await client_session.initialize() + + # Run the test callback + result = await test_callback(client_session) + + # Cancel the server task + tg.cancel_scope.cancel() + + return result + + +@pytest.mark.anyio +async def test_content_only_without_output_schema(): + """Test returning content only when no outputSchema is defined.""" + tools = [ + Tool( + name="echo", + description="Echo a message", + inputSchema={ + "type": "object", + "properties": { + "message": {"type": "string"}, + }, + "required": ["message"], + }, + # No outputSchema defined + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: + if name == "echo": + return [TextContent(type="text", text=f"Echo: {arguments['message']}")] + else: # pragma: no cover + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("echo", {"message": "Hello"}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Echo: Hello" + assert result.structuredContent is None + + +@pytest.mark.anyio +async def test_dict_only_without_output_schema(): + """Test returning dict only when no outputSchema is defined.""" + tools = [ + Tool( + name="get_info", + description="Get structured information", + inputSchema={ + "type": "object", + "properties": {}, + }, + # No outputSchema defined + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + if name == "get_info": + return {"status": "ok", "data": {"value": 42}} + else: # pragma: no cover + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("get_info", {}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + # Check that the content is the JSON serialization + assert json.loads(result.content[0].text) == {"status": "ok", "data": {"value": 42}} + assert result.structuredContent == {"status": "ok", "data": {"value": 42}} + + +@pytest.mark.anyio +async def test_both_content_and_dict_without_output_schema(): + """Test returning both content and dict when no outputSchema is defined.""" + tools = [ + Tool( + name="process", + description="Process data", + inputSchema={ + "type": "object", + "properties": {}, + }, + # No outputSchema defined + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> tuple[list[TextContent], dict[str, Any]]: + if name == "process": + content = [TextContent(type="text", text="Processing complete")] + data = {"result": "success", "count": 10} + return (content, data) + else: # pragma: no cover + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("process", {}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Processing complete" + assert result.structuredContent == {"result": "success", "count": 10} + + +@pytest.mark.anyio +async def test_content_only_with_output_schema_error(): + """Test error when outputSchema is defined but only content is returned.""" + tools = [ + Tool( + name="structured_tool", + description="Tool expecting structured output", + inputSchema={ + "type": "object", + "properties": {}, + }, + outputSchema={ + "type": "object", + "properties": { + "result": {"type": "string"}, + }, + "required": ["result"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: + # This returns only content, but outputSchema expects structured data + return [TextContent(type="text", text="This is not structured")] + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("structured_tool", {}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify error + assert result is not None + assert result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert "Output validation error: outputSchema defined but no structured output returned" in result.content[0].text + + +@pytest.mark.anyio +async def test_valid_dict_with_output_schema(): + """Test valid dict output matching outputSchema.""" + tools = [ + Tool( + name="calc", + description="Calculate result", + inputSchema={ + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + }, + "required": ["x", "y"], + }, + outputSchema={ + "type": "object", + "properties": { + "sum": {"type": "number"}, + "product": {"type": "number"}, + }, + "required": ["sum", "product"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + if name == "calc": + x = arguments["x"] + y = arguments["y"] + return {"sum": x + y, "product": x * y} + else: # pragma: no cover + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("calc", {"x": 3, "y": 4}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + # Check JSON serialization + assert json.loads(result.content[0].text) == {"sum": 7, "product": 12} + assert result.structuredContent == {"sum": 7, "product": 12} + + +@pytest.mark.anyio +async def test_invalid_dict_with_output_schema(): + """Test dict output that doesn't match outputSchema.""" + tools = [ + Tool( + name="user_info", + description="Get user information", + inputSchema={ + "type": "object", + "properties": {}, + }, + outputSchema={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + }, + "required": ["name", "age"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + if name == "user_info": + # Missing required 'age' field + return {"name": "Alice"} + else: # pragma: no cover + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("user_info", {}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify error + assert result is not None + assert result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert "Output validation error:" in result.content[0].text + assert "'age' is a required property" in result.content[0].text + + +@pytest.mark.anyio +async def test_both_content_and_valid_dict_with_output_schema(): + """Test returning both content and valid dict with outputSchema.""" + tools = [ + Tool( + name="analyze", + description="Analyze data", + inputSchema={ + "type": "object", + "properties": { + "text": {"type": "string"}, + }, + "required": ["text"], + }, + outputSchema={ + "type": "object", + "properties": { + "sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + }, + "required": ["sentiment", "confidence"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> tuple[list[TextContent], dict[str, Any]]: + if name == "analyze": + content = [TextContent(type="text", text=f"Analysis of: {arguments['text']}")] + data = {"sentiment": "positive", "confidence": 0.95} + return (content, data) + else: # pragma: no cover + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("analyze", {"text": "Great job!"}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert result.content[0].text == "Analysis of: Great job!" + assert result.structuredContent == {"sentiment": "positive", "confidence": 0.95} + + +@pytest.mark.anyio +async def test_tool_call_result(): + """Test returning ToolCallResult when no outputSchema is defined.""" + tools = [ + Tool( + name="get_info", + description="Get structured information", + inputSchema={ + "type": "object", + "properties": {}, + }, + # No outputSchema for direct return of tool call result + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> CallToolResult: + if name == "get_info": + return CallToolResult( + content=[TextContent(type="text", text="Results calculated")], + structuredContent={"status": "ok", "data": {"value": 42}}, + _meta={"some": "metadata"}, + ) + else: # pragma: no cover + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("get_info", {}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert result.content[0].text == "Results calculated" + assert isinstance(result.content[0], TextContent) + assert result.structuredContent == {"status": "ok", "data": {"value": 42}} + assert result.meta == {"some": "metadata"} + + +@pytest.mark.anyio +async def test_output_schema_type_validation(): + """Test outputSchema validates types correctly.""" + tools = [ + Tool( + name="stats", + description="Get statistics", + inputSchema={ + "type": "object", + "properties": {}, + }, + outputSchema={ + "type": "object", + "properties": { + "count": {"type": "integer"}, + "average": {"type": "number"}, + "items": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["count", "average", "items"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + if name == "stats": + # Wrong type for 'count' - should be integer + return {"count": "five", "average": 2.5, "items": ["a", "b"]} + else: # pragma: no cover + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("stats", {}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify error + assert result is not None + assert result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert "Output validation error:" in result.content[0].text + assert "'five' is not of type 'integer'" in result.content[0].text diff --git a/tests/server/test_lowlevel_tool_annotations.py b/tests/server/test_lowlevel_tool_annotations.py new file mode 100644 index 0000000000..f812c48777 --- /dev/null +++ b/tests/server/test_lowlevel_tool_annotations.py @@ -0,0 +1,100 @@ +"""Tests for tool annotations in low-level server.""" + +import anyio +import pytest + +from mcp.client.session import ClientSession +from mcp.server import Server +from mcp.server.lowlevel import NotificationOptions +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder +from mcp.types import ClientResult, ServerNotification, ServerRequest, Tool, ToolAnnotations + + +@pytest.mark.anyio +async def test_lowlevel_server_tool_annotations(): + """Test that tool annotations work in low-level server.""" + server = Server("test") + + # Create a tool with annotations + @server.list_tools() + async def list_tools(): # pragma: no cover + return [ + Tool( + name="echo", + description="Echo a message back", + inputSchema={ + "type": "object", + "properties": { + "message": {"type": "string"}, + }, + "required": ["message"], + }, + annotations=ToolAnnotations( + title="Echo Tool", + readOnlyHint=True, + ), + ) + ] + + tools_result = None + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + # Message handler for client + async def message_handler( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): # pragma: no cover + raise message + + # Server task + async def run_server(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) as server_session: + async with anyio.create_task_group() as tg: + + async def handle_messages(): + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, {}, False) + + tg.start_soon(handle_messages) + await anyio.sleep_forever() + + # Run the test + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=message_handler, + ) as client_session: + # Initialize the session + await client_session.initialize() + + # List tools + tools_result = await client_session.list_tools() + + # Cancel the server task + tg.cancel_scope.cancel() + + # Verify results + assert tools_result is not None + assert len(tools_result.tools) == 1 + assert tools_result.tools[0].name == "echo" + assert tools_result.tools[0].annotations is not None + assert tools_result.tools[0].annotations.title == "Echo Tool" + assert tools_result.tools[0].annotations.readOnlyHint is True diff --git a/tests/server/test_read_resource.py b/tests/server/test_read_resource.py index 469eef8570..c31b90c557 100644 --- a/tests/server/test_read_resource.py +++ b/tests/server/test_read_resource.py @@ -18,7 +18,7 @@ def temp_file(): yield path try: path.unlink() - except FileNotFoundError: + except FileNotFoundError: # pragma: no cover pass @@ -35,7 +35,6 @@ async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: # Create a request request = types.ReadResourceRequest( - method="resources/read", params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())), ) @@ -56,18 +55,13 @@ async def test_read_resource_binary(temp_file: Path): @server.read_resource() async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: - return [ - ReadResourceContents( - content=b"Hello World", mime_type="application/octet-stream" - ) - ] + return [ReadResourceContents(content=b"Hello World", mime_type="application/octet-stream")] # Get the handler directly from the server handler = server.request_handlers[types.ReadResourceRequest] # Create a request request = types.ReadResourceRequest( - method="resources/read", params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())), ) @@ -99,7 +93,6 @@ async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: # Create a request request = types.ReadResourceRequest( - method="resources/read", params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())), ) diff --git a/tests/server/test_session.py b/tests/server/test_session.py index 561a94b64b..34f9c6e28e 100644 --- a/tests/server/test_session.py +++ b/tests/server/test_session.py @@ -1,3 +1,5 @@ +from typing import Any + import anyio import pytest @@ -7,31 +9,34 @@ from mcp.server.lowlevel import NotificationOptions from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession +from mcp.shared.exceptions import McpError +from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder from mcp.types import ( ClientNotification, + Completion, + CompletionArgument, + CompletionContext, + CompletionsCapability, InitializedNotification, - JSONRPCMessage, + Prompt, + PromptReference, PromptsCapability, + Resource, ResourcesCapability, + ResourceTemplateReference, ServerCapabilities, ) @pytest.mark.anyio async def test_server_session_initialize(): - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[ - JSONRPCMessage - ](1) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[ - JSONRPCMessage - ](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) # Create a message handler to catch exceptions - async def message_handler( - message: RequestResponder[types.ServerRequest, types.ClientResult] - | types.ServerNotification - | Exception, + async def message_handler( # pragma: no cover + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, ) -> None: if isinstance(message, Exception): raise message @@ -50,13 +55,13 @@ async def run_server(): capabilities=ServerCapabilities(), ), ) as server_session: - async for message in server_session.incoming_messages: - if isinstance(message, Exception): + async for message in server_session.incoming_messages: # pragma: no branch + if isinstance(message, Exception): # pragma: no cover raise message if isinstance(message, ClientNotification) and isinstance( message.root, InitializedNotification - ): + ): # pragma: no branch received_initialized = True return @@ -72,7 +77,7 @@ async def run_server(): tg.start_soon(run_server) await client_session.initialize() - except anyio.ClosedResourceError: + except anyio.ClosedResourceError: # pragma: no cover pass assert received_initialized @@ -82,27 +87,440 @@ async def run_server(): async def test_server_capabilities(): server = Server("test") notification_options = NotificationOptions() - experimental_capabilities = {} + experimental_capabilities: dict[str, Any] = {} # Initially no capabilities caps = server.get_capabilities(notification_options, experimental_capabilities) assert caps.prompts is None assert caps.resources is None + assert caps.completions is None # Add a prompts handler @server.list_prompts() - async def list_prompts(): + async def list_prompts() -> list[Prompt]: # pragma: no cover return [] caps = server.get_capabilities(notification_options, experimental_capabilities) assert caps.prompts == PromptsCapability(listChanged=False) assert caps.resources is None + assert caps.completions is None # Add a resources handler @server.list_resources() - async def list_resources(): + async def list_resources() -> list[Resource]: # pragma: no cover return [] caps = server.get_capabilities(notification_options, experimental_capabilities) assert caps.prompts == PromptsCapability(listChanged=False) assert caps.resources == ResourcesCapability(subscribe=False, listChanged=False) + assert caps.completions is None + + # Add a complete handler + @server.completion() + async def complete( # pragma: no cover + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, + ) -> Completion | None: + return Completion( + values=["completion1", "completion2"], + ) + + caps = server.get_capabilities(notification_options, experimental_capabilities) + assert caps.prompts == PromptsCapability(listChanged=False) + assert caps.resources == ResourcesCapability(subscribe=False, listChanged=False) + assert caps.completions == CompletionsCapability() + + +@pytest.mark.anyio +async def test_server_session_initialize_with_older_protocol_version(): + """Test that server accepts and responds with older protocol (2024-11-05).""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) + + received_initialized = False + received_protocol_version = None + + async def run_server(): + nonlocal received_initialized + + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="mcp", + server_version="0.1.0", + capabilities=ServerCapabilities(), + ), + ) as server_session: + async for message in server_session.incoming_messages: # pragma: no branch + if isinstance(message, Exception): # pragma: no cover + raise message + + if isinstance(message, types.ClientNotification) and isinstance( + message.root, InitializedNotification + ): # pragma: no branch + received_initialized = True + return + + async def mock_client(): + nonlocal received_protocol_version + + # Send initialization request with older protocol version (2024-11-05) + await client_to_server_send.send( + SessionMessage( + types.JSONRPCMessage( + types.JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="initialize", + params=types.InitializeRequestParams( + protocolVersion="2024-11-05", + capabilities=types.ClientCapabilities(), + clientInfo=types.Implementation(name="test-client", version="1.0.0"), + ).model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + + # Wait for the initialize response + init_response_message = await server_to_client_receive.receive() + assert isinstance(init_response_message.message.root, types.JSONRPCResponse) + result_data = init_response_message.message.root.result + init_result = types.InitializeResult.model_validate(result_data) + + # Check that the server responded with the requested protocol version + received_protocol_version = init_result.protocolVersion + assert received_protocol_version == "2024-11-05" + + # Send initialized notification + await client_to_server_send.send( + SessionMessage( + types.JSONRPCMessage( + types.JSONRPCNotification( + jsonrpc="2.0", + method="notifications/initialized", + ) + ) + ) + ) + + async with ( + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + anyio.create_task_group() as tg, + ): + tg.start_soon(run_server) + tg.start_soon(mock_client) + + assert received_initialized + assert received_protocol_version == "2024-11-05" + + +@pytest.mark.anyio +async def test_ping_request_before_initialization(): + """Test that ping requests are allowed before initialization is complete.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) + + ping_response_received = False + ping_response_id = None + + async def run_server(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="mcp", + server_version="0.1.0", + capabilities=ServerCapabilities(), + ), + ) as server_session: + async for message in server_session.incoming_messages: # pragma: no branch + if isinstance(message, Exception): # pragma: no cover + raise message + + # We should receive a ping request before initialization + if isinstance(message, RequestResponder) and isinstance( + message.request.root, types.PingRequest + ): # pragma: no branch + # Respond to the ping + with message: + await message.respond(types.ServerResult(types.EmptyResult())) + return + + async def mock_client(): + nonlocal ping_response_received, ping_response_id + + # Send ping request before any initialization + await client_to_server_send.send( + SessionMessage( + types.JSONRPCMessage( + types.JSONRPCRequest( + jsonrpc="2.0", + id=42, + method="ping", + ) + ) + ) + ) + + # Wait for the ping response + ping_response_message = await server_to_client_receive.receive() + assert isinstance(ping_response_message.message.root, types.JSONRPCResponse) + + ping_response_received = True + ping_response_id = ping_response_message.message.root.id + + async with ( + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + anyio.create_task_group() as tg, + ): + tg.start_soon(run_server) + tg.start_soon(mock_client) + + assert ping_response_received + assert ping_response_id == 42 + + +@pytest.mark.anyio +async def test_create_message_tool_result_validation(): + """Test tool_use/tool_result validation in create_message.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) + + async with ( + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test", + server_version="0.1.0", + capabilities=ServerCapabilities(), + ), + ) as session: + # Set up client params with sampling.tools capability for the test + session._client_params = types.InitializeRequestParams( + protocolVersion=types.LATEST_PROTOCOL_VERSION, + capabilities=types.ClientCapabilities( + sampling=types.SamplingCapability(tools=types.SamplingToolsCapability()) + ), + clientInfo=types.Implementation(name="test", version="1.0"), + ) + + tool = types.Tool(name="test_tool", inputSchema={"type": "object"}) + text = types.TextContent(type="text", text="hello") + tool_use = types.ToolUseContent(type="tool_use", id="call_1", name="test_tool", input={}) + tool_result = types.ToolResultContent(type="tool_result", toolUseId="call_1", content=[]) + + # Case 1: tool_result mixed with other content + with pytest.raises(ValueError, match="only tool_result content"): + await session.create_message( + messages=[ + types.SamplingMessage(role="user", content=text), + types.SamplingMessage(role="assistant", content=tool_use), + types.SamplingMessage(role="user", content=[tool_result, text]), # mixed! + ], + max_tokens=100, + tools=[tool], + ) + + # Case 2: tool_result without previous message + with pytest.raises(ValueError, match="requires a previous message"): + await session.create_message( + messages=[types.SamplingMessage(role="user", content=tool_result)], + max_tokens=100, + tools=[tool], + ) + + # Case 3: tool_result without previous tool_use + with pytest.raises(ValueError, match="do not match any tool_use"): + await session.create_message( + messages=[ + types.SamplingMessage(role="user", content=text), + types.SamplingMessage(role="user", content=tool_result), + ], + max_tokens=100, + tools=[tool], + ) + + # Case 4: mismatched tool IDs + with pytest.raises(ValueError, match="ids of tool_result blocks and tool_use blocks"): + await session.create_message( + messages=[ + types.SamplingMessage(role="user", content=text), + types.SamplingMessage(role="assistant", content=tool_use), + types.SamplingMessage( + role="user", + content=types.ToolResultContent(type="tool_result", toolUseId="wrong_id", content=[]), + ), + ], + max_tokens=100, + tools=[tool], + ) + + # Case 5: text-only message with tools (no tool_results) - passes validation + # Covers has_tool_results=False branch. + # We use move_on_after because validation happens synchronously before + # send_request, which would block indefinitely waiting for a response. + # The timeout lets validation pass, then cancels the blocked send. + with anyio.move_on_after(0.01): + await session.create_message( + messages=[types.SamplingMessage(role="user", content=text)], + max_tokens=100, + tools=[tool], + ) + + # Case 6: valid matching tool_result/tool_use IDs - passes validation + # Covers tool_use_ids == tool_result_ids branch. + # (see Case 5 comment for move_on_after explanation) + with anyio.move_on_after(0.01): + await session.create_message( + messages=[ + types.SamplingMessage(role="user", content=text), + types.SamplingMessage(role="assistant", content=tool_use), + types.SamplingMessage(role="user", content=tool_result), + ], + max_tokens=100, + tools=[tool], + ) + + # Case 7: validation runs even without `tools` parameter + # (tool loop continuation may omit tools while containing tool_result) + with pytest.raises(ValueError, match="do not match any tool_use"): + await session.create_message( + messages=[ + types.SamplingMessage(role="user", content=text), + types.SamplingMessage(role="user", content=tool_result), + ], + max_tokens=100, + # Note: no tools parameter + ) + + # Case 8: empty messages list - skips validation entirely + # Covers the `if messages:` branch (line 280->302) + with anyio.move_on_after(0.01): + await session.create_message( + messages=[], + max_tokens=100, + ) + + +@pytest.mark.anyio +async def test_create_message_without_tools_capability(): + """Test that create_message raises McpError when tools are provided without capability.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) + + async with ( + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test", + server_version="0.1.0", + capabilities=ServerCapabilities(), + ), + ) as session: + # Set up client params WITHOUT sampling.tools capability + session._client_params = types.InitializeRequestParams( + protocolVersion=types.LATEST_PROTOCOL_VERSION, + capabilities=types.ClientCapabilities(sampling=types.SamplingCapability()), + clientInfo=types.Implementation(name="test", version="1.0"), + ) + + tool = types.Tool(name="test_tool", inputSchema={"type": "object"}) + text = types.TextContent(type="text", text="hello") + + # Should raise McpError when tools are provided but client lacks capability + with pytest.raises(McpError) as exc_info: + await session.create_message( + messages=[types.SamplingMessage(role="user", content=text)], + max_tokens=100, + tools=[tool], + ) + assert "does not support sampling tools capability" in exc_info.value.error.message + + # Should also raise McpError when tool_choice is provided + with pytest.raises(McpError) as exc_info: + await session.create_message( + messages=[types.SamplingMessage(role="user", content=text)], + max_tokens=100, + tool_choice=types.ToolChoice(mode="auto"), + ) + assert "does not support sampling tools capability" in exc_info.value.error.message + + +@pytest.mark.anyio +async def test_other_requests_blocked_before_initialization(): + """Test that non-ping requests are still blocked before initialization.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) + + error_response_received = False + error_code = None + + async def run_server(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="mcp", + server_version="0.1.0", + capabilities=ServerCapabilities(), + ), + ): + # Server should handle the request and send an error response + # No need to process incoming_messages since the error is handled automatically + await anyio.sleep(0.1) # Give time for the request to be processed + + async def mock_client(): + nonlocal error_response_received, error_code + + # Try to send a non-ping request before initialization + await client_to_server_send.send( + SessionMessage( + types.JSONRPCMessage( + types.JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="prompts/list", + ) + ) + ) + ) + + # Wait for the error response + error_message = await server_to_client_receive.receive() + if isinstance(error_message.message.root, types.JSONRPCError): # pragma: no branch + error_response_received = True + error_code = error_message.message.root.error.code + + async with ( + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + anyio.create_task_group() as tg, + ): + tg.start_soon(run_server) + tg.start_soon(mock_client) + + assert error_response_received + assert error_code == types.INVALID_PARAMS diff --git a/tests/server/test_session_race_condition.py b/tests/server/test_session_race_condition.py new file mode 100644 index 0000000000..b5388167ad --- /dev/null +++ b/tests/server/test_session_race_condition.py @@ -0,0 +1,155 @@ +""" +Test for race condition fix in initialization flow. + +This test verifies that requests can be processed immediately after +responding to InitializeRequest, without waiting for InitializedNotification. + +This is critical for HTTP transport where requests can arrive in any order. +""" + +import anyio +import pytest + +import mcp.types as types +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder +from mcp.types import ServerCapabilities, Tool + + +@pytest.mark.anyio +async def test_request_immediately_after_initialize_response(): + """ + Test that requests are accepted immediately after initialize response. + + This reproduces the race condition in stateful HTTP mode where: + 1. Client sends InitializeRequest + 2. Server responds with InitializeResult + 3. Client immediately sends tools/list (before server receives InitializedNotification) + 4. Without fix: Server rejects with "Received request before initialization was complete" + 5. With fix: Server accepts and processes the request + + This test simulates the HTTP transport behavior where InitializedNotification + may arrive in a separate POST request after other requests. + """ + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](10) + + tools_list_success = False + error_received = None + + async def run_server(): + nonlocal tools_list_success + + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=ServerCapabilities( + tools=types.ToolsCapability(listChanged=False), + ), + ), + ) as server_session: + async for message in server_session.incoming_messages: # pragma: no branch + if isinstance(message, Exception): # pragma: no cover + raise message + + # Handle tools/list request + if isinstance(message, RequestResponder): + if isinstance(message.request.root, types.ListToolsRequest): # pragma: no branch + tools_list_success = True + # Respond with a tool list + with message: + await message.respond( + types.ServerResult( + types.ListToolsResult( + tools=[ + Tool( + name="example_tool", + description="An example tool", + inputSchema={"type": "object", "properties": {}}, + ) + ] + ) + ) + ) + + # Handle InitializedNotification + if isinstance(message, types.ClientNotification): + if isinstance(message.root, types.InitializedNotification): # pragma: no branch + # Done - exit gracefully + return + + async def mock_client(): + nonlocal error_received + + # Step 1: Send InitializeRequest + await client_to_server_send.send( + SessionMessage( + types.JSONRPCMessage( + types.JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="initialize", + params=types.InitializeRequestParams( + protocolVersion=types.LATEST_PROTOCOL_VERSION, + capabilities=types.ClientCapabilities(), + clientInfo=types.Implementation(name="test-client", version="1.0.0"), + ).model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + + # Step 2: Wait for InitializeResult + init_msg = await server_to_client_receive.receive() + assert isinstance(init_msg.message.root, types.JSONRPCResponse) + + # Step 3: Immediately send tools/list BEFORE InitializedNotification + # This is the race condition scenario + await client_to_server_send.send( + SessionMessage( + types.JSONRPCMessage( + types.JSONRPCRequest( + jsonrpc="2.0", + id=2, + method="tools/list", + ) + ) + ) + ) + + # Step 4: Check the response + tools_msg = await server_to_client_receive.receive() + if isinstance(tools_msg.message.root, types.JSONRPCError): # pragma: no cover + error_received = tools_msg.message.root.error.message + + # Step 5: Send InitializedNotification + await client_to_server_send.send( + SessionMessage( + types.JSONRPCMessage( + types.JSONRPCNotification( + jsonrpc="2.0", + method="notifications/initialized", + ) + ) + ) + ) + + async with ( + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + anyio.create_task_group() as tg, + ): + tg.start_soon(run_server) + tg.start_soon(mock_client) + + # With the PR fix: tools_list_success should be True, error_received should be None + # Without the fix: error_received would contain "Received request before initialization was complete" + assert tools_list_success, f"tools/list should have succeeded. Error received: {error_received}" + assert error_received is None, f"Expected no error, but got: {error_received}" diff --git a/tests/server/test_sse_security.py b/tests/server/test_sse_security.py new file mode 100644 index 0000000000..010eaf6a25 --- /dev/null +++ b/tests/server/test_sse_security.py @@ -0,0 +1,293 @@ +"""Tests for SSE server DNS rebinding protection.""" + +import logging +import multiprocessing +import socket + +import httpx +import pytest +import uvicorn +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Mount, Route + +from mcp.server import Server +from mcp.server.sse import SseServerTransport +from mcp.server.transport_security import TransportSecuritySettings +from mcp.types import Tool +from tests.test_helpers import wait_for_server + +logger = logging.getLogger(__name__) +SERVER_NAME = "test_sse_security_server" + + +@pytest.fixture +def server_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def server_url(server_port: int) -> str: # pragma: no cover + return f"http://127.0.0.1:{server_port}" + + +class SecurityTestServer(Server): # pragma: no cover + def __init__(self): + super().__init__(SERVER_NAME) + + async def on_list_tools(self) -> list[Tool]: + return [] + + +def run_server_with_settings(port: int, security_settings: TransportSecuritySettings | None = None): # pragma: no cover + """Run the SSE server with specified security settings.""" + app = SecurityTestServer() + sse_transport = SseServerTransport("/messages/", security_settings) + + async def handle_sse(request: Request): + try: + async with sse_transport.connect_sse(request.scope, request.receive, request._send) as streams: + if streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + except ValueError as e: + # Validation error was already handled inside connect_sse + logger.debug(f"SSE connection failed validation: {e}") + return Response() + + routes = [ + Route("/sse", endpoint=handle_sse), + Mount("/messages/", app=sse_transport.handle_post_message), + ] + + starlette_app = Starlette(routes=routes) + uvicorn.run(starlette_app, host="127.0.0.1", port=port, log_level="error") + + +def start_server_process(port: int, security_settings: TransportSecuritySettings | None = None): + """Start server in a separate process.""" + process = multiprocessing.Process(target=run_server_with_settings, args=(port, security_settings)) + process.start() + # Wait for server to be ready to accept connections + wait_for_server(port) + return process + + +@pytest.mark.anyio +async def test_sse_security_default_settings(server_port: int): + """Test SSE with default security settings (protection disabled).""" + process = start_server_process(server_port) + + try: + headers = {"Host": "evil.com", "Origin": "http://evil.com"} + + async with httpx.AsyncClient(timeout=5.0) as client: + async with client.stream("GET", f"http://127.0.0.1:{server_port}/sse", headers=headers) as response: + assert response.status_code == 200 + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_sse_security_invalid_host_header(server_port: int): + """Test SSE with invalid Host header.""" + # Enable security by providing settings with an empty allowed_hosts list + security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["example.com"]) + process = start_server_process(server_port, security_settings) + + try: + # Test with invalid host header + headers = {"Host": "evil.com"} + + async with httpx.AsyncClient() as client: + response = await client.get(f"http://127.0.0.1:{server_port}/sse", headers=headers) + assert response.status_code == 421 + assert response.text == "Invalid Host header" + + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_sse_security_invalid_origin_header(server_port: int): + """Test SSE with invalid Origin header.""" + # Configure security to allow the host but restrict origins + security_settings = TransportSecuritySettings( + enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"], allowed_origins=["http://localhost:*"] + ) + process = start_server_process(server_port, security_settings) + + try: + # Test with invalid origin header + headers = {"Origin": "http://evil.com"} + + async with httpx.AsyncClient() as client: + response = await client.get(f"http://127.0.0.1:{server_port}/sse", headers=headers) + assert response.status_code == 403 + assert response.text == "Invalid Origin header" + + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_sse_security_post_invalid_content_type(server_port: int): + """Test POST endpoint with invalid Content-Type header.""" + # Configure security to allow the host + security_settings = TransportSecuritySettings( + enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"], allowed_origins=["http://127.0.0.1:*"] + ) + process = start_server_process(server_port, security_settings) + + try: + async with httpx.AsyncClient(timeout=5.0) as client: + # Test POST with invalid content type + fake_session_id = "12345678123456781234567812345678" + response = await client.post( + f"http://127.0.0.1:{server_port}/messages/?session_id={fake_session_id}", + headers={"Content-Type": "text/plain"}, + content="test", + ) + assert response.status_code == 400 + assert response.text == "Invalid Content-Type header" + + # Test POST with missing content type + response = await client.post( + f"http://127.0.0.1:{server_port}/messages/?session_id={fake_session_id}", content="test" + ) + assert response.status_code == 400 + assert response.text == "Invalid Content-Type header" + + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_sse_security_disabled(server_port: int): + """Test SSE with security disabled.""" + settings = TransportSecuritySettings(enable_dns_rebinding_protection=False) + process = start_server_process(server_port, settings) + + try: + # Test with invalid host header - should still work + headers = {"Host": "evil.com"} + + async with httpx.AsyncClient(timeout=5.0) as client: + # For SSE endpoints, we need to use stream to avoid timeout + async with client.stream("GET", f"http://127.0.0.1:{server_port}/sse", headers=headers) as response: + # Should connect successfully even with invalid host + assert response.status_code == 200 + + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_sse_security_custom_allowed_hosts(server_port: int): + """Test SSE with custom allowed hosts.""" + settings = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=["localhost", "127.0.0.1", "custom.host"], + allowed_origins=["http://localhost", "http://127.0.0.1", "http://custom.host"], + ) + process = start_server_process(server_port, settings) + + try: + # Test with custom allowed host + headers = {"Host": "custom.host"} + + async with httpx.AsyncClient(timeout=5.0) as client: + # For SSE endpoints, we need to use stream to avoid timeout + async with client.stream("GET", f"http://127.0.0.1:{server_port}/sse", headers=headers) as response: + # Should connect successfully with custom host + assert response.status_code == 200 + + # Test with non-allowed host + headers = {"Host": "evil.com"} + + async with httpx.AsyncClient() as client: + response = await client.get(f"http://127.0.0.1:{server_port}/sse", headers=headers) + assert response.status_code == 421 + assert response.text == "Invalid Host header" + + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_sse_security_wildcard_ports(server_port: int): + """Test SSE with wildcard port patterns.""" + settings = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=["localhost:*", "127.0.0.1:*"], + allowed_origins=["http://localhost:*", "http://127.0.0.1:*"], + ) + process = start_server_process(server_port, settings) + + try: + # Test with various port numbers + for test_port in [8080, 3000, 9999]: + headers = {"Host": f"localhost:{test_port}"} + + async with httpx.AsyncClient(timeout=5.0) as client: + # For SSE endpoints, we need to use stream to avoid timeout + async with client.stream("GET", f"http://127.0.0.1:{server_port}/sse", headers=headers) as response: + # Should connect successfully with any port + assert response.status_code == 200 + + headers = {"Origin": f"http://localhost:{test_port}"} + + async with httpx.AsyncClient(timeout=5.0) as client: + # For SSE endpoints, we need to use stream to avoid timeout + async with client.stream("GET", f"http://127.0.0.1:{server_port}/sse", headers=headers) as response: + # Should connect successfully with any port + assert response.status_code == 200 + + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_sse_security_post_valid_content_type(server_port: int): + """Test POST endpoint with valid Content-Type headers.""" + # Configure security to allow the host + security_settings = TransportSecuritySettings( + enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"], allowed_origins=["http://127.0.0.1:*"] + ) + process = start_server_process(server_port, security_settings) + + try: + async with httpx.AsyncClient() as client: + # Test with various valid content types + valid_content_types = [ + "application/json", + "application/json; charset=utf-8", + "application/json;charset=utf-8", + "APPLICATION/JSON", # Case insensitive + ] + + for content_type in valid_content_types: + # Use a valid UUID format (even though session won't exist) + fake_session_id = "12345678123456781234567812345678" + response = await client.post( + f"http://127.0.0.1:{server_port}/messages/?session_id={fake_session_id}", + headers={"Content-Type": content_type}, + json={"test": "data"}, + ) + # Will get 404 because session doesn't exist, but that's OK + # We're testing that it passes the content-type check + assert response.status_code == 404 + assert response.text == "Could not find session" + + finally: + process.terminate() + process.join() diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 85c5bf219b..13cdde3d61 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -4,6 +4,7 @@ import pytest from mcp.server.stdio import stdio_server +from mcp.shared.message import SessionMessage from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse @@ -21,26 +22,23 @@ async def test_stdio_server(): stdin.write(message.model_dump_json(by_alias=True, exclude_none=True) + "\n") stdin.seek(0) - async with stdio_server( - stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout) - ) as (read_stream, write_stream): - received_messages = [] + async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as ( + read_stream, + write_stream, + ): + received_messages: list[JSONRPCMessage] = [] async with read_stream: async for message in read_stream: - if isinstance(message, Exception): + if isinstance(message, Exception): # pragma: no cover raise message - received_messages.append(message) + received_messages.append(message.message) if len(received_messages) == 2: break # Verify received messages assert len(received_messages) == 2 - assert received_messages[0] == JSONRPCMessage( - root=JSONRPCRequest(jsonrpc="2.0", id=1, method="ping") - ) - assert received_messages[1] == JSONRPCMessage( - root=JSONRPCResponse(jsonrpc="2.0", id=2, result={}) - ) + assert received_messages[0] == JSONRPCMessage(root=JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")) + assert received_messages[1] == JSONRPCMessage(root=JSONRPCResponse(jsonrpc="2.0", id=2, result={})) # Test sending responses from the server responses = [ @@ -50,19 +48,14 @@ async def test_stdio_server(): async with write_stream: for response in responses: - await write_stream.send(response) + session_message = SessionMessage(response) + await write_stream.send(session_message) stdout.seek(0) output_lines = stdout.readlines() assert len(output_lines) == 2 - received_responses = [ - JSONRPCMessage.model_validate_json(line.strip()) for line in output_lines - ] + received_responses = [JSONRPCMessage.model_validate_json(line.strip()) for line in output_lines] assert len(received_responses) == 2 - assert received_responses[0] == JSONRPCMessage( - root=JSONRPCRequest(jsonrpc="2.0", id=3, method="ping") - ) - assert received_responses[1] == JSONRPCMessage( - root=JSONRPCResponse(jsonrpc="2.0", id=4, result={}) - ) + assert received_responses[0] == JSONRPCMessage(root=JSONRPCRequest(jsonrpc="2.0", id=3, method="ping")) + assert received_responses[1] == JSONRPCMessage(root=JSONRPCResponse(jsonrpc="2.0", id=4, result={})) diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py new file mode 100644 index 0000000000..6fcf08aa00 --- /dev/null +++ b/tests/server/test_streamable_http_manager.py @@ -0,0 +1,264 @@ +"""Tests for StreamableHTTPSessionManager.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +import anyio +import pytest +from starlette.types import Message + +from mcp.server import streamable_http_manager +from mcp.server.lowlevel import Server +from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager + + +@pytest.mark.anyio +async def test_run_can_only_be_called_once(): + """Test that run() can only be called once per instance.""" + app = Server("test-server") + manager = StreamableHTTPSessionManager(app=app) + + # First call should succeed + async with manager.run(): + pass + + # Second call should raise RuntimeError + with pytest.raises(RuntimeError) as excinfo: + async with manager.run(): + pass # pragma: no cover + + assert "StreamableHTTPSessionManager .run() can only be called once per instance" in str(excinfo.value) + + +@pytest.mark.anyio +async def test_run_prevents_concurrent_calls(): + """Test that concurrent calls to run() are prevented.""" + app = Server("test-server") + manager = StreamableHTTPSessionManager(app=app) + + errors: list[Exception] = [] + + async def try_run(): + try: + async with manager.run(): + # Simulate some work + await anyio.sleep(0.1) + except RuntimeError as e: + errors.append(e) + + # Try to run concurrently + async with anyio.create_task_group() as tg: + tg.start_soon(try_run) + tg.start_soon(try_run) + + # One should succeed, one should fail + assert len(errors) == 1 + assert "StreamableHTTPSessionManager .run() can only be called once per instance" in str(errors[0]) + + +@pytest.mark.anyio +async def test_handle_request_without_run_raises_error(): + """Test that handle_request raises error if run() hasn't been called.""" + app = Server("test-server") + manager = StreamableHTTPSessionManager(app=app) + + # Mock ASGI parameters + scope = {"type": "http", "method": "POST", "path": "/test"} + + async def receive(): # pragma: no cover + return {"type": "http.request", "body": b""} + + async def send(message: Message): # pragma: no cover + pass + + # Should raise error because run() hasn't been called + with pytest.raises(RuntimeError) as excinfo: + await manager.handle_request(scope, receive, send) + + assert "Task group is not initialized. Make sure to use run()." in str(excinfo.value) + + +class TestException(Exception): + __test__ = False # Prevent pytest from collecting this as a test class + pass + + +@pytest.fixture +async def running_manager(): + app = Server("test-cleanup-server") + # It's important that the app instance used by the manager is the one we can patch + manager = StreamableHTTPSessionManager(app=app) + async with manager.run(): + # Patch app.run here if it's simpler, or patch it within the test + yield manager, app + + +@pytest.mark.anyio +async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[StreamableHTTPSessionManager, Server]): + manager, app = running_manager + + mock_mcp_run = AsyncMock(return_value=None) + # This will be called by StreamableHTTPSessionManager's run_server -> self.app.run + app.run = mock_mcp_run + + sent_messages: list[Message] = [] + + async def mock_send(message: Message): + sent_messages.append(message) + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [(b"content-type", b"application/json")], + } + + async def mock_receive(): # pragma: no cover + return {"type": "http.request", "body": b"", "more_body": False} + + # Trigger session creation + await manager.handle_request(scope, mock_receive, mock_send) + + # Extract session ID from response headers + session_id = None + for msg in sent_messages: # pragma: no branch + if msg["type"] == "http.response.start": # pragma: no branch + for header_name, header_value in msg.get("headers", []): # pragma: no branch + if header_name.decode().lower() == MCP_SESSION_ID_HEADER.lower(): + session_id = header_value.decode() + break + if session_id: # Break outer loop if session_id is found # pragma: no branch + break + + assert session_id is not None, "Session ID not found in response headers" + + # Ensure MCPServer.run was called + mock_mcp_run.assert_called_once() + + # At this point, mock_mcp_run has completed, and the finally block in + # StreamableHTTPSessionManager's run_server should have executed. + + # To ensure the task spawned by handle_request finishes and cleanup occurs: + # Give other tasks a chance to run. This is important for the finally block. + await anyio.sleep(0.01) + + assert session_id not in manager._server_instances, ( + "Session ID should be removed from _server_instances after graceful exit" + ) + assert not manager._server_instances, "No sessions should be tracked after the only session exits gracefully" + + +@pytest.mark.anyio +async def test_stateful_session_cleanup_on_exception(running_manager: tuple[StreamableHTTPSessionManager, Server]): + manager, app = running_manager + + mock_mcp_run = AsyncMock(side_effect=TestException("Simulated crash")) + app.run = mock_mcp_run + + sent_messages: list[Message] = [] + + async def mock_send(message: Message): + sent_messages.append(message) + # If an exception occurs, the transport might try to send an error response + # For this test, we mostly care that the session is established enough + # to get an ID + if message["type"] == "http.response.start" and message["status"] >= 500: # pragma: no cover + pass # Expected if TestException propagates that far up the transport + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [(b"content-type", b"application/json")], + } + + async def mock_receive(): # pragma: no cover + return {"type": "http.request", "body": b"", "more_body": False} + + # Trigger session creation + await manager.handle_request(scope, mock_receive, mock_send) + + session_id = None + for msg in sent_messages: # pragma: no branch + if msg["type"] == "http.response.start": # pragma: no branch + for header_name, header_value in msg.get("headers", []): # pragma: no branch + if header_name.decode().lower() == MCP_SESSION_ID_HEADER.lower(): + session_id = header_value.decode() + break + if session_id: # Break outer loop if session_id is found # pragma: no branch + break + + assert session_id is not None, "Session ID not found in response headers" + + mock_mcp_run.assert_called_once() + + # Give other tasks a chance to run to ensure the finally block executes + await anyio.sleep(0.01) + + assert session_id not in manager._server_instances, ( + "Session ID should be removed from _server_instances after an exception" + ) + assert not manager._server_instances, "No sessions should be tracked after the only session crashes" + + +@pytest.mark.anyio +async def test_stateless_requests_memory_cleanup(): + """Test that stateless requests actually clean up resources using real transports.""" + app = Server("test-stateless-real-cleanup") + manager = StreamableHTTPSessionManager(app=app, stateless=True) + + # Track created transport instances + created_transports: list[StreamableHTTPServerTransport] = [] + + # Patch StreamableHTTPServerTransport constructor to track instances + + original_constructor = streamable_http_manager.StreamableHTTPServerTransport + + def track_transport(*args: Any, **kwargs: Any) -> StreamableHTTPServerTransport: + transport = original_constructor(*args, **kwargs) + created_transports.append(transport) + return transport + + with patch.object(streamable_http_manager, "StreamableHTTPServerTransport", side_effect=track_transport): + async with manager.run(): + # Mock app.run to complete immediately + app.run = AsyncMock(return_value=None) + + # Send a simple request + sent_messages: list[Message] = [] + + async def mock_send(message: Message): + sent_messages.append(message) + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [ + (b"content-type", b"application/json"), + (b"accept", b"application/json, text/event-stream"), + ], + } + + # Empty body to trigger early return + async def mock_receive(): + return { + "type": "http.request", + "body": b"", + "more_body": False, + } + + # Send a request + await manager.handle_request(scope, mock_receive, mock_send) + + # Verify transport was created + assert len(created_transports) == 1, "Should have created one transport" + + transport = created_transports[0] + + # The key assertion - transport should be terminated + assert transport._terminated, "Transport should be terminated after stateless request" + + # Verify internal state is cleaned up + assert len(transport._request_streams) == 0, "Transport should have no active request streams" diff --git a/tests/server/test_streamable_http_security.py b/tests/server/test_streamable_http_security.py new file mode 100644 index 0000000000..a637b1dce0 --- /dev/null +++ b/tests/server/test_streamable_http_security.py @@ -0,0 +1,293 @@ +"""Tests for StreamableHTTP server DNS rebinding protection.""" + +import logging +import multiprocessing +import socket +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +import httpx +import pytest +import uvicorn +from starlette.applications import Starlette +from starlette.routing import Mount +from starlette.types import Receive, Scope, Send + +from mcp.server import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.server.transport_security import TransportSecuritySettings +from mcp.types import Tool +from tests.test_helpers import wait_for_server + +logger = logging.getLogger(__name__) +SERVER_NAME = "test_streamable_http_security_server" + + +@pytest.fixture +def server_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def server_url(server_port: int) -> str: # pragma: no cover + return f"http://127.0.0.1:{server_port}" + + +class SecurityTestServer(Server): # pragma: no cover + def __init__(self): + super().__init__(SERVER_NAME) + + async def on_list_tools(self) -> list[Tool]: + return [] + + +def run_server_with_settings(port: int, security_settings: TransportSecuritySettings | None = None): # pragma: no cover + """Run the StreamableHTTP server with specified security settings.""" + app = SecurityTestServer() + + # Create session manager with security settings + session_manager = StreamableHTTPSessionManager( + app=app, + json_response=False, + stateless=False, + security_settings=security_settings, + ) + + # Create the ASGI handler + async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None: + await session_manager.handle_request(scope, receive, send) + + # Create Starlette app with lifespan + @asynccontextmanager + async def lifespan(app: Starlette) -> AsyncGenerator[None, None]: + async with session_manager.run(): + yield + + routes = [ + Mount("/", app=handle_streamable_http), + ] + + starlette_app = Starlette(routes=routes, lifespan=lifespan) + uvicorn.run(starlette_app, host="127.0.0.1", port=port, log_level="error") + + +def start_server_process(port: int, security_settings: TransportSecuritySettings | None = None): + """Start server in a separate process.""" + process = multiprocessing.Process(target=run_server_with_settings, args=(port, security_settings)) + process.start() + # Wait for server to be ready to accept connections + wait_for_server(port) + return process + + +@pytest.mark.anyio +async def test_streamable_http_security_default_settings(server_port: int): + """Test StreamableHTTP with default security settings (protection enabled).""" + process = start_server_process(server_port) + + try: + # Test with valid localhost headers + async with httpx.AsyncClient(timeout=5.0) as client: + # POST request to initialize session + response = await client.post( + f"http://127.0.0.1:{server_port}/", + json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + ) + assert response.status_code == 200 + assert "mcp-session-id" in response.headers + + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_streamable_http_security_invalid_host_header(server_port: int): + """Test StreamableHTTP with invalid Host header.""" + security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True) + process = start_server_process(server_port, security_settings) + + try: + # Test with invalid host header + headers = { + "Host": "evil.com", + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + } + + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post( + f"http://127.0.0.1:{server_port}/", + json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, + headers=headers, + ) + assert response.status_code == 421 + assert response.text == "Invalid Host header" + + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_streamable_http_security_invalid_origin_header(server_port: int): + """Test StreamableHTTP with invalid Origin header.""" + security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"]) + process = start_server_process(server_port, security_settings) + + try: + # Test with invalid origin header + headers = { + "Origin": "http://evil.com", + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + } + + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post( + f"http://127.0.0.1:{server_port}/", + json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, + headers=headers, + ) + assert response.status_code == 403 + assert response.text == "Invalid Origin header" + + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_streamable_http_security_invalid_content_type(server_port: int): + """Test StreamableHTTP POST with invalid Content-Type header.""" + process = start_server_process(server_port) + + try: + async with httpx.AsyncClient(timeout=5.0) as client: + # Test POST with invalid content type + response = await client.post( + f"http://127.0.0.1:{server_port}/", + headers={ + "Content-Type": "text/plain", + "Accept": "application/json, text/event-stream", + }, + content="test", + ) + assert response.status_code == 400 + assert response.text == "Invalid Content-Type header" + + # Test POST with missing content type + response = await client.post( + f"http://127.0.0.1:{server_port}/", + headers={"Accept": "application/json, text/event-stream"}, + content="test", + ) + assert response.status_code == 400 + assert response.text == "Invalid Content-Type header" + + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_streamable_http_security_disabled(server_port: int): + """Test StreamableHTTP with security disabled.""" + settings = TransportSecuritySettings(enable_dns_rebinding_protection=False) + process = start_server_process(server_port, settings) + + try: + # Test with invalid host header - should still work + headers = { + "Host": "evil.com", + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + } + + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post( + f"http://127.0.0.1:{server_port}/", + json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, + headers=headers, + ) + # Should connect successfully even with invalid host + assert response.status_code == 200 + + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_streamable_http_security_custom_allowed_hosts(server_port: int): + """Test StreamableHTTP with custom allowed hosts.""" + settings = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=["localhost", "127.0.0.1", "custom.host"], + allowed_origins=["http://localhost", "http://127.0.0.1", "http://custom.host"], + ) + process = start_server_process(server_port, settings) + + try: + # Test with custom allowed host + headers = { + "Host": "custom.host", + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + } + + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post( + f"http://127.0.0.1:{server_port}/", + json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, + headers=headers, + ) + # Should connect successfully with custom host + assert response.status_code == 200 + finally: + process.terminate() + process.join() + + +@pytest.mark.anyio +async def test_streamable_http_security_get_request(server_port: int): + """Test StreamableHTTP GET request with security.""" + security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1"]) + process = start_server_process(server_port, security_settings) + + try: + # Test GET request with invalid host header + headers = { + "Host": "evil.com", + "Accept": "text/event-stream", + } + + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(f"http://127.0.0.1:{server_port}/", headers=headers) + assert response.status_code == 421 + assert response.text == "Invalid Host header" + + # Test GET request with valid host header + headers = { + "Host": "127.0.0.1", + "Accept": "text/event-stream", + } + + async with httpx.AsyncClient(timeout=5.0) as client: + # GET requests need a session ID in StreamableHTTP + # So it will fail with "Missing session ID" not security error + response = await client.get(f"http://127.0.0.1:{server_port}/", headers=headers) + # This should pass security but fail on session validation + assert response.status_code == 400 + body = response.json() + assert "Missing session ID" in body["error"]["message"] + + finally: + process.terminate() + process.join() diff --git a/tests/server/test_validation.py b/tests/server/test_validation.py new file mode 100644 index 0000000000..56044460df --- /dev/null +++ b/tests/server/test_validation.py @@ -0,0 +1,141 @@ +"""Tests for server validation functions.""" + +import pytest + +from mcp.server.validation import ( + check_sampling_tools_capability, + validate_sampling_tools, + validate_tool_use_result_messages, +) +from mcp.shared.exceptions import McpError +from mcp.types import ( + ClientCapabilities, + SamplingCapability, + SamplingMessage, + SamplingToolsCapability, + TextContent, + Tool, + ToolChoice, + ToolResultContent, + ToolUseContent, +) + + +class TestCheckSamplingToolsCapability: + """Tests for check_sampling_tools_capability function.""" + + def test_returns_false_when_caps_none(self) -> None: + """Returns False when client_caps is None.""" + assert check_sampling_tools_capability(None) is False + + def test_returns_false_when_sampling_none(self) -> None: + """Returns False when client_caps.sampling is None.""" + caps = ClientCapabilities() + assert check_sampling_tools_capability(caps) is False + + def test_returns_false_when_tools_none(self) -> None: + """Returns False when client_caps.sampling.tools is None.""" + caps = ClientCapabilities(sampling=SamplingCapability()) + assert check_sampling_tools_capability(caps) is False + + def test_returns_true_when_tools_present(self) -> None: + """Returns True when sampling.tools is present.""" + caps = ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())) + assert check_sampling_tools_capability(caps) is True + + +class TestValidateSamplingTools: + """Tests for validate_sampling_tools function.""" + + def test_no_error_when_tools_none(self) -> None: + """No error when tools and tool_choice are None.""" + validate_sampling_tools(None, None, None) # Should not raise + + def test_raises_when_tools_provided_but_no_capability(self) -> None: + """Raises McpError when tools provided but client doesn't support.""" + tool = Tool(name="test", inputSchema={"type": "object"}) + with pytest.raises(McpError) as exc_info: + validate_sampling_tools(None, [tool], None) + assert "sampling tools capability" in str(exc_info.value) + + def test_raises_when_tool_choice_provided_but_no_capability(self) -> None: + """Raises McpError when tool_choice provided but client doesn't support.""" + with pytest.raises(McpError) as exc_info: + validate_sampling_tools(None, None, ToolChoice(mode="auto")) + assert "sampling tools capability" in str(exc_info.value) + + def test_no_error_when_capability_present(self) -> None: + """No error when client has sampling.tools capability.""" + caps = ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())) + tool = Tool(name="test", inputSchema={"type": "object"}) + validate_sampling_tools(caps, [tool], ToolChoice(mode="auto")) # Should not raise + + +class TestValidateToolUseResultMessages: + """Tests for validate_tool_use_result_messages function.""" + + def test_no_error_for_empty_messages(self) -> None: + """No error when messages list is empty.""" + validate_tool_use_result_messages([]) # Should not raise + + def test_no_error_for_simple_text_messages(self) -> None: + """No error for simple text messages.""" + messages = [ + SamplingMessage(role="user", content=TextContent(type="text", text="Hello")), + SamplingMessage(role="assistant", content=TextContent(type="text", text="Hi")), + ] + validate_tool_use_result_messages(messages) # Should not raise + + def test_raises_when_tool_result_mixed_with_other_content(self) -> None: + """Raises when tool_result is mixed with other content types.""" + messages = [ + SamplingMessage( + role="user", + content=[ + ToolResultContent(type="tool_result", toolUseId="123"), + TextContent(type="text", text="also this"), + ], + ), + ] + with pytest.raises(ValueError, match="only tool_result content"): + validate_tool_use_result_messages(messages) + + def test_raises_when_tool_result_without_previous_tool_use(self) -> None: + """Raises when tool_result appears without preceding tool_use.""" + messages = [ + SamplingMessage( + role="user", + content=ToolResultContent(type="tool_result", toolUseId="123"), + ), + ] + with pytest.raises(ValueError, match="previous message containing tool_use"): + validate_tool_use_result_messages(messages) + + def test_raises_when_tool_result_ids_dont_match_tool_use(self) -> None: + """Raises when tool_result IDs don't match tool_use IDs.""" + messages = [ + SamplingMessage( + role="assistant", + content=ToolUseContent(type="tool_use", id="tool-1", name="test", input={}), + ), + SamplingMessage( + role="user", + content=ToolResultContent(type="tool_result", toolUseId="tool-2"), + ), + ] + with pytest.raises(ValueError, match="do not match"): + validate_tool_use_result_messages(messages) + + def test_no_error_when_tool_result_matches_tool_use(self) -> None: + """No error when tool_result IDs match tool_use IDs.""" + messages = [ + SamplingMessage( + role="assistant", + content=ToolUseContent(type="tool_use", id="tool-1", name="test", input={}), + ), + SamplingMessage( + role="user", + content=ToolResultContent(type="tool_result", toolUseId="tool-1"), + ), + ] + validate_tool_use_result_messages(messages) # Should not raise diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py new file mode 100644 index 0000000000..bd9f5a934d --- /dev/null +++ b/tests/shared/test_auth.py @@ -0,0 +1,61 @@ +"""Tests for OAuth 2.0 shared code.""" + +from mcp.shared.auth import OAuthMetadata + + +class TestOAuthMetadata: + """Tests for OAuthMetadata parsing.""" + + def test_oauth(self): + """Should not throw when parsing OAuth metadata.""" + OAuthMetadata.model_validate( + { + "issuer": "https://example.com", + "authorization_endpoint": "https://example.com/oauth2/authorize", + "token_endpoint": "https://example.com/oauth2/token", + "scopes_supported": ["read", "write"], + "response_types_supported": ["code", "token"], + "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], + } + ) + + def test_oidc(self): + """Should not throw when parsing OIDC metadata.""" + OAuthMetadata.model_validate( + { + "issuer": "https://example.com", + "authorization_endpoint": "https://example.com/oauth2/authorize", + "token_endpoint": "https://example.com/oauth2/token", + "end_session_endpoint": "https://example.com/logout", + "id_token_signing_alg_values_supported": ["RS256"], + "jwks_uri": "https://example.com/.well-known/jwks.json", + "response_types_supported": ["code", "token"], + "revocation_endpoint": "https://example.com/oauth2/revoke", + "scopes_supported": ["openid", "read", "write"], + "subject_types_supported": ["public"], + "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], + "userinfo_endpoint": "https://example.com/oauth2/userInfo", + } + ) + + def test_oauth_with_jarm(self): + """Should not throw when parsing OAuth metadata that includes JARM response modes.""" + OAuthMetadata.model_validate( + { + "issuer": "https://example.com", + "authorization_endpoint": "https://example.com/oauth2/authorize", + "token_endpoint": "https://example.com/oauth2/token", + "scopes_supported": ["read", "write"], + "response_types_supported": ["code", "token"], + "response_modes_supported": [ + "query", + "fragment", + "form_post", + "query.jwt", + "fragment.jwt", + "form_post.jwt", + "jwt", + ], + "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], + } + ) diff --git a/tests/shared/test_auth_utils.py b/tests/shared/test_auth_utils.py new file mode 100644 index 0000000000..5b12dc6775 --- /dev/null +++ b/tests/shared/test_auth_utils.py @@ -0,0 +1,112 @@ +"""Tests for OAuth 2.0 Resource Indicators utilities.""" + +from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url + + +class TestResourceUrlFromServerUrl: + """Tests for resource_url_from_server_url function.""" + + def test_removes_fragment(self): + """Fragment should be removed per RFC 8707.""" + assert resource_url_from_server_url("https://example.com/path#fragment") == "https://example.com/path" + assert resource_url_from_server_url("https://example.com/#fragment") == "https://example.com/" + + def test_preserves_path(self): + """Path should be preserved.""" + assert ( + resource_url_from_server_url("https://example.com/path/to/resource") + == "https://example.com/path/to/resource" + ) + assert resource_url_from_server_url("https://example.com/") == "https://example.com/" + assert resource_url_from_server_url("https://example.com") == "https://example.com" + + def test_preserves_query(self): + """Query parameters should be preserved.""" + assert resource_url_from_server_url("https://example.com/path?foo=bar") == "https://example.com/path?foo=bar" + assert resource_url_from_server_url("https://example.com/?key=value") == "https://example.com/?key=value" + + def test_preserves_port(self): + """Non-default ports should be preserved.""" + assert resource_url_from_server_url("https://example.com:8443/path") == "https://example.com:8443/path" + assert resource_url_from_server_url("http://example.com:8080/") == "http://example.com:8080/" + + def test_lowercase_scheme_and_host(self): + """Scheme and host should be lowercase for canonical form.""" + assert resource_url_from_server_url("HTTPS://EXAMPLE.COM/path") == "https://example.com/path" + assert resource_url_from_server_url("Http://Example.Com:8080/") == "http://example.com:8080/" + + def test_handles_pydantic_urls(self): + """Should handle Pydantic URL types.""" + from pydantic import HttpUrl + + url = HttpUrl("https://example.com/path") + assert resource_url_from_server_url(url) == "https://example.com/path" + + +class TestCheckResourceAllowed: + """Tests for check_resource_allowed function.""" + + def test_identical_urls(self): + """Identical URLs should match.""" + assert check_resource_allowed("https://example.com/path", "https://example.com/path") is True + assert check_resource_allowed("https://example.com/", "https://example.com/") is True + assert check_resource_allowed("https://example.com", "https://example.com") is True + + def test_different_schemes(self): + """Different schemes should not match.""" + assert check_resource_allowed("https://example.com/path", "http://example.com/path") is False + assert check_resource_allowed("http://example.com/", "https://example.com/") is False + + def test_different_domains(self): + """Different domains should not match.""" + assert check_resource_allowed("https://example.com/path", "https://example.org/path") is False + assert check_resource_allowed("https://sub.example.com/", "https://example.com/") is False + + def test_different_ports(self): + """Different ports should not match.""" + assert check_resource_allowed("https://example.com:8443/path", "https://example.com/path") is False + assert check_resource_allowed("https://example.com:8080/", "https://example.com:8443/") is False + + def test_hierarchical_matching(self): + """Child paths should match parent paths.""" + # Parent resource allows child resources + assert check_resource_allowed("https://example.com/api/v1/users", "https://example.com/api") is True + assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api") is True + assert check_resource_allowed("https://example.com/mcp/server", "https://example.com/mcp") is True + + # Exact match + assert check_resource_allowed("https://example.com/api", "https://example.com/api") is True + + # Parent cannot use child's token + assert check_resource_allowed("https://example.com/api", "https://example.com/api/v1") is False + assert check_resource_allowed("https://example.com/", "https://example.com/api") is False + + def test_path_boundary_matching(self): + """Path matching should respect boundaries.""" + # Should not match partial path segments + assert check_resource_allowed("https://example.com/apiextra", "https://example.com/api") is False + assert check_resource_allowed("https://example.com/api123", "https://example.com/api") is False + + # Should match with trailing slash + assert check_resource_allowed("https://example.com/api/", "https://example.com/api") is True + assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/") is True + + def test_trailing_slash_handling(self): + """Trailing slashes should be handled correctly.""" + # With and without trailing slashes + assert check_resource_allowed("https://example.com/api/", "https://example.com/api") is True + assert check_resource_allowed("https://example.com/api", "https://example.com/api/") is False + assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api") is True + assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/") is True + + def test_case_insensitive_origin(self): + """Origin comparison should be case-insensitive.""" + assert check_resource_allowed("https://EXAMPLE.COM/path", "https://example.com/path") is True + assert check_resource_allowed("HTTPS://example.com/path", "https://example.com/path") is True + assert check_resource_allowed("https://Example.Com:8080/api", "https://example.com:8080/api") is True + + def test_empty_paths(self): + """Empty paths should be handled correctly.""" + assert check_resource_allowed("https://example.com", "https://example.com") is True + assert check_resource_allowed("https://example.com/", "https://example.com") is True + assert check_resource_allowed("https://example.com/api", "https://example.com") is True diff --git a/tests/shared/test_exceptions.py b/tests/shared/test_exceptions.py new file mode 100644 index 0000000000..8845dfe781 --- /dev/null +++ b/tests/shared/test_exceptions.py @@ -0,0 +1,159 @@ +"""Tests for MCP exception classes.""" + +import pytest + +from mcp.shared.exceptions import McpError, UrlElicitationRequiredError +from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData + + +class TestUrlElicitationRequiredError: + """Tests for UrlElicitationRequiredError exception class.""" + + def test_create_with_single_elicitation(self) -> None: + """Test creating error with a single elicitation.""" + elicitation = ElicitRequestURLParams( + mode="url", + message="Auth required", + url="https://example.com/auth", + elicitationId="test-123", + ) + error = UrlElicitationRequiredError([elicitation]) + + assert error.error.code == URL_ELICITATION_REQUIRED + assert error.error.message == "URL elicitation required" + assert len(error.elicitations) == 1 + assert error.elicitations[0].elicitationId == "test-123" + + def test_create_with_multiple_elicitations(self) -> None: + """Test creating error with multiple elicitations uses plural message.""" + elicitations = [ + ElicitRequestURLParams( + mode="url", + message="Auth 1", + url="https://example.com/auth1", + elicitationId="test-1", + ), + ElicitRequestURLParams( + mode="url", + message="Auth 2", + url="https://example.com/auth2", + elicitationId="test-2", + ), + ] + error = UrlElicitationRequiredError(elicitations) + + assert error.error.message == "URL elicitations required" # Plural + assert len(error.elicitations) == 2 + + def test_custom_message(self) -> None: + """Test creating error with a custom message.""" + elicitation = ElicitRequestURLParams( + mode="url", + message="Auth required", + url="https://example.com/auth", + elicitationId="test-123", + ) + error = UrlElicitationRequiredError([elicitation], message="Custom message") + + assert error.error.message == "Custom message" + + def test_from_error_data(self) -> None: + """Test reconstructing error from ErrorData.""" + error_data = ErrorData( + code=URL_ELICITATION_REQUIRED, + message="URL elicitation required", + data={ + "elicitations": [ + { + "mode": "url", + "message": "Auth required", + "url": "https://example.com/auth", + "elicitationId": "test-123", + } + ] + }, + ) + + error = UrlElicitationRequiredError.from_error(error_data) + + assert len(error.elicitations) == 1 + assert error.elicitations[0].elicitationId == "test-123" + assert error.elicitations[0].url == "https://example.com/auth" + + def test_from_error_data_wrong_code(self) -> None: + """Test that from_error raises ValueError for wrong error code.""" + error_data = ErrorData( + code=-32600, # Wrong code + message="Some other error", + data={}, + ) + + with pytest.raises(ValueError, match="Expected error code"): + UrlElicitationRequiredError.from_error(error_data) + + def test_serialization_roundtrip(self) -> None: + """Test that error can be serialized and reconstructed.""" + original = UrlElicitationRequiredError( + [ + ElicitRequestURLParams( + mode="url", + message="Auth required", + url="https://example.com/auth", + elicitationId="test-123", + ) + ] + ) + + # Simulate serialization over wire + error_data = original.error + + # Reconstruct + reconstructed = UrlElicitationRequiredError.from_error(error_data) + + assert reconstructed.elicitations[0].elicitationId == original.elicitations[0].elicitationId + assert reconstructed.elicitations[0].url == original.elicitations[0].url + assert reconstructed.elicitations[0].message == original.elicitations[0].message + + def test_error_data_contains_elicitations(self) -> None: + """Test that error data contains properly serialized elicitations.""" + elicitation = ElicitRequestURLParams( + mode="url", + message="Please authenticate", + url="https://example.com/oauth", + elicitationId="oauth-flow-1", + ) + error = UrlElicitationRequiredError([elicitation]) + + assert error.error.data is not None + assert "elicitations" in error.error.data + elicit_data = error.error.data["elicitations"][0] + assert elicit_data["mode"] == "url" + assert elicit_data["message"] == "Please authenticate" + assert elicit_data["url"] == "https://example.com/oauth" + assert elicit_data["elicitationId"] == "oauth-flow-1" + + def test_inherits_from_mcp_error(self) -> None: + """Test that UrlElicitationRequiredError inherits from McpError.""" + elicitation = ElicitRequestURLParams( + mode="url", + message="Auth required", + url="https://example.com/auth", + elicitationId="test-123", + ) + error = UrlElicitationRequiredError([elicitation]) + + assert isinstance(error, McpError) + assert isinstance(error, Exception) + + def test_exception_message(self) -> None: + """Test that exception message is set correctly.""" + elicitation = ElicitRequestURLParams( + mode="url", + message="Auth required", + url="https://example.com/auth", + elicitationId="test-123", + ) + error = UrlElicitationRequiredError([elicitation]) + + # The exception's string representation should match the message + assert str(error) == "URL elicitation required" diff --git a/tests/shared/test_httpx_utils.py b/tests/shared/test_httpx_utils.py new file mode 100644 index 0000000000..dcc6fd003c --- /dev/null +++ b/tests/shared/test_httpx_utils.py @@ -0,0 +1,24 @@ +"""Tests for httpx utility functions.""" + +import httpx + +from mcp.shared._httpx_utils import create_mcp_http_client + + +def test_default_settings(): + """Test that default settings are applied correctly.""" + client = create_mcp_http_client() + + assert client.follow_redirects is True + assert client.timeout.connect == 30.0 + + +def test_custom_parameters(): + """Test custom headers and timeout are set correctly.""" + headers = {"Authorization": "Bearer token"} + timeout = httpx.Timeout(60.0) + + client = create_mcp_http_client(headers, timeout) + + assert client.headers["Authorization"] == "Bearer token" + assert client.timeout.connect == 60.0 diff --git a/tests/shared/test_memory.py b/tests/shared/test_memory.py index a0c32f556c..ca4368e9f8 100644 --- a/tests/shared/test_memory.py +++ b/tests/shared/test_memory.py @@ -4,13 +4,8 @@ from mcp.client.session import ClientSession from mcp.server import Server -from mcp.shared.memory import ( - create_connected_server_and_client_session, -) -from mcp.types import ( - EmptyResult, - Resource, -) +from mcp.shared.memory import create_connected_server_and_client_session +from mcp.types import EmptyResult, Resource @pytest.fixture @@ -18,7 +13,7 @@ def mcp_server() -> Server: server = Server(name="test_server") @server.list_resources() - async def handle_list_resources(): + async def handle_list_resources(): # pragma: no cover return [ Resource( uri=AnyUrl("memory://test"), diff --git a/tests/shared/test_progress_notifications.py b/tests/shared/test_progress_notifications.py new file mode 100644 index 0000000000..1552711d2e --- /dev/null +++ b/tests/shared/test_progress_notifications.py @@ -0,0 +1,392 @@ +from typing import Any, cast +from unittest.mock import patch + +import anyio +import pytest + +import mcp.types as types +from mcp.client.session import ClientSession +from mcp.server import Server +from mcp.server.lowlevel import NotificationOptions +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.context import RequestContext +from mcp.shared.memory import create_connected_server_and_client_session +from mcp.shared.progress import progress +from mcp.shared.session import BaseSession, RequestResponder, SessionMessage + + +@pytest.mark.anyio +async def test_bidirectional_progress_notifications(): + """Test that both client and server can send progress notifications.""" + # Create memory streams for client/server + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](5) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](5) + + # Run a server session so we can send progress updates in tool + async def run_server(): + # Create a server session + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="ProgressTestServer", + server_version="0.1.0", + capabilities=server.get_capabilities(NotificationOptions(), {}), + ), + ) as server_session: + global serv_sesh + + serv_sesh = server_session + async for message in server_session.incoming_messages: + try: + await server._handle_message(message, server_session, {}) + except Exception as e: # pragma: no cover + raise e + + # Track progress updates + server_progress_updates: list[dict[str, Any]] = [] + client_progress_updates: list[dict[str, Any]] = [] + + # Progress tokens + server_progress_token = "server_token_123" + client_progress_token = "client_token_456" + + # Create a server with progress capability + server = Server(name="ProgressTestServer") + + # Register progress handler + @server.progress_notification() + async def handle_progress( + progress_token: str | int, + progress: float, + total: float | None, + message: str | None, + ): + server_progress_updates.append( + { + "token": progress_token, + "progress": progress, + "total": total, + "message": message, + } + ) + + # Register list tool handler + @server.list_tools() + async def handle_list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="test_tool", + description="A tool that sends progress notifications list[types.TextContent]: + # Make sure we received a progress token + if name == "test_tool": + if arguments and "_meta" in arguments: + progressToken = arguments["_meta"]["progressToken"] + + if not progressToken: # pragma: no cover + raise ValueError("Empty progress token received") + + if progressToken != client_progress_token: # pragma: no cover + raise ValueError("Server sending back incorrect progressToken") + + # Send progress notifications + await serv_sesh.send_progress_notification( + progress_token=progressToken, + progress=0.25, + total=1.0, + message="Server progress 25%", + ) + + await serv_sesh.send_progress_notification( + progress_token=progressToken, + progress=0.5, + total=1.0, + message="Server progress 50%", + ) + + await serv_sesh.send_progress_notification( + progress_token=progressToken, + progress=1.0, + total=1.0, + message="Server progress 100%", + ) + + else: # pragma: no cover + raise ValueError("Progress token not sent.") + + return [types.TextContent(type="text", text="Tool executed successfully")] + + raise ValueError(f"Unknown tool: {name}") # pragma: no cover + + # Client message handler to store progress notifications + async def handle_client_message( + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): # pragma: no cover + raise message + + if isinstance(message, types.ServerNotification): # pragma: no branch + if isinstance(message.root, types.ProgressNotification): # pragma: no branch + params = message.root.params + client_progress_updates.append( + { + "token": params.progressToken, + "progress": params.progress, + "total": params.total, + "message": params.message, + } + ) + + # Test using client + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=handle_client_message, + ) as client_session, + anyio.create_task_group() as tg, + ): + # Start the server in a background task + tg.start_soon(run_server) + + # Initialize the client connection + await client_session.initialize() + + # Call list_tools with progress token + await client_session.list_tools() + + # Call test_tool with progress token + await client_session.call_tool("test_tool", {"_meta": {"progressToken": client_progress_token}}) + + # Send progress notifications from client to server + await client_session.send_progress_notification( + progress_token=server_progress_token, + progress=0.33, + total=1.0, + message="Client progress 33%", + ) + + await client_session.send_progress_notification( + progress_token=server_progress_token, + progress=0.66, + total=1.0, + message="Client progress 66%", + ) + + await client_session.send_progress_notification( + progress_token=server_progress_token, + progress=1.0, + total=1.0, + message="Client progress 100%", + ) + + # Wait and exit + await anyio.sleep(0.5) + tg.cancel_scope.cancel() + + # Verify client received progress updates from server + assert len(client_progress_updates) == 3 + assert client_progress_updates[0]["token"] == client_progress_token + assert client_progress_updates[0]["progress"] == 0.25 + assert client_progress_updates[0]["message"] == "Server progress 25%" + assert client_progress_updates[2]["progress"] == 1.0 + + # Verify server received progress updates from client + assert len(server_progress_updates) == 3 + assert server_progress_updates[0]["token"] == server_progress_token + assert server_progress_updates[0]["progress"] == 0.33 + assert server_progress_updates[0]["message"] == "Client progress 33%" + assert server_progress_updates[2]["progress"] == 1.0 + + +@pytest.mark.anyio +async def test_progress_context_manager(): + """Test client using progress context manager for sending progress notifications.""" + # Create memory streams for client/server + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](5) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](5) + + # Track progress updates + server_progress_updates: list[dict[str, Any]] = [] + + server = Server(name="ProgressContextTestServer") + + progress_token = None + + # Register progress handler + @server.progress_notification() + async def handle_progress( + progress_token: str | int, + progress: float, + total: float | None, + message: str | None, + ): + server_progress_updates.append( + {"token": progress_token, "progress": progress, "total": total, "message": message} + ) + + # Run server session to receive progress updates + async def run_server(): + # Create a server session + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="ProgressContextTestServer", + server_version="0.1.0", + capabilities=server.get_capabilities(NotificationOptions(), {}), + ), + ) as server_session: + async for message in server_session.incoming_messages: + try: + await server._handle_message(message, server_session, {}) + except Exception as e: # pragma: no cover + raise e + + # Client message handler + async def handle_client_message( + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): # pragma: no cover + raise message + + # run client session + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=handle_client_message, + ) as client_session, + anyio.create_task_group() as tg, + ): + tg.start_soon(run_server) + + await client_session.initialize() + + progress_token = "client_token_456" + + # Create request context + meta = types.RequestParams.Meta(progressToken=progress_token) + request_context = RequestContext( + request_id="test-request", + session=client_session, + meta=meta, + lifespan_context=None, + ) + + # cast for type checker + typed_context = cast(RequestContext[BaseSession[Any, Any, Any, Any, Any], Any], request_context) + + # Utilize progress context manager + with progress(typed_context, total=100) as p: + await p.progress(10, message="Loading configuration...") + await p.progress(30, message="Connecting to database...") + await p.progress(40, message="Fetching data...") + await p.progress(20, message="Processing results...") + + # Wait for all messages to be processed + await anyio.sleep(0.5) + tg.cancel_scope.cancel() + + # Verify progress updates were received by server + assert len(server_progress_updates) == 4 + + # first update + assert server_progress_updates[0]["token"] == progress_token + assert server_progress_updates[0]["progress"] == 10 + assert server_progress_updates[0]["total"] == 100 + assert server_progress_updates[0]["message"] == "Loading configuration..." + + # second update + assert server_progress_updates[1]["token"] == progress_token + assert server_progress_updates[1]["progress"] == 40 + assert server_progress_updates[1]["total"] == 100 + assert server_progress_updates[1]["message"] == "Connecting to database..." + + # third update + assert server_progress_updates[2]["token"] == progress_token + assert server_progress_updates[2]["progress"] == 80 + assert server_progress_updates[2]["total"] == 100 + assert server_progress_updates[2]["message"] == "Fetching data..." + + # final update + assert server_progress_updates[3]["token"] == progress_token + assert server_progress_updates[3]["progress"] == 100 + assert server_progress_updates[3]["total"] == 100 + assert server_progress_updates[3]["message"] == "Processing results..." + + +@pytest.mark.anyio +async def test_progress_callback_exception_logging(): + """Test that exceptions in progress callbacks are logged and \ + don't crash the session.""" + # Track logged warnings + logged_errors: list[str] = [] + + def mock_log_error(msg: str, *args: Any) -> None: + logged_errors.append(msg % args if args else msg) + + # Create a progress callback that raises an exception + async def failing_progress_callback( + progress: float, total: float | None, message: str | None + ) -> None: # pragma: no cover + raise ValueError("Progress callback failed!") + + # Create a server with a tool that sends progress notifications + server = Server(name="TestProgressServer") + + @server.call_tool() + async def handle_call_tool(name: str, arguments: Any) -> list[types.TextContent]: + if name == "progress_tool": + # Send a progress notification + await server.request_context.session.send_progress_notification( + progress_token=server.request_context.request_id, + progress=50.0, + total=100.0, + message="Halfway done", + ) + return [types.TextContent(type="text", text="progress_result")] + raise ValueError(f"Unknown tool: {name}") # pragma: no cover + + @server.list_tools() + async def handle_list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="progress_tool", + description="A tool that sends progress notifications", + inputSchema={}, + ) + ] + + # Test with mocked logging + with patch("mcp.shared.session.logging.error", side_effect=mock_log_error): + async with create_connected_server_and_client_session(server) as client_session: + # Send a request with a failing progress callback + result = await client_session.send_request( + types.ClientRequest( + types.CallToolRequest( + method="tools/call", + params=types.CallToolRequestParams(name="progress_tool", arguments={}), + ) + ), + types.CallToolResult, + progress_callback=failing_progress_callback, + ) + + # Verify the request completed successfully despite the callback failure + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, types.TextContent) + assert content.text == "progress_result" + + # Check that a warning was logged for the progress callback exception + assert len(logged_errors) > 0 + assert any("Progress callback raised an exception" in warning for warning in logged_errors) diff --git a/tests/shared/test_session.py b/tests/shared/test_session.py index 59cb30c86c..b355a4bf2d 100644 --- a/tests/shared/test_session.py +++ b/tests/shared/test_session.py @@ -1,4 +1,5 @@ from collections.abc import AsyncGenerator +from typing import Any import anyio import pytest @@ -7,13 +8,20 @@ from mcp.client.session import ClientSession from mcp.server.lowlevel.server import Server from mcp.shared.exceptions import McpError -from mcp.shared.memory import create_connected_server_and_client_session +from mcp.shared.memory import create_client_server_memory_streams, create_connected_server_and_client_session +from mcp.shared.message import SessionMessage from mcp.types import ( CancelledNotification, CancelledNotificationParams, ClientNotification, ClientRequest, EmptyResult, + ErrorData, + JSONRPCError, + JSONRPCMessage, + JSONRPCRequest, + JSONRPCResponse, + TextContent, ) @@ -58,14 +66,14 @@ def make_server() -> Server: # Register the tool handler @server.call_tool() - async def handle_call_tool(name: str, arguments: dict | None) -> list: + async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[TextContent]: nonlocal request_id, ev_tool_called if name == "slow_tool": request_id = server.request_context.request_id ev_tool_called.set() await anyio.sleep(10) # Long enough to ensure we can cancel - return [] - raise ValueError(f"Unknown tool: {name}") + return [] # pragma: no cover + raise ValueError(f"Unknown tool: {name}") # pragma: no cover # Register the tool so it shows up in list_tools @server.list_tools() @@ -80,29 +88,24 @@ async def handle_list_tools() -> list[types.Tool]: return server - async def make_request(client_session): + async def make_request(client_session: ClientSession): nonlocal ev_cancelled try: await client_session.send_request( ClientRequest( types.CallToolRequest( - method="tools/call", - params=types.CallToolRequestParams( - name="slow_tool", arguments={} - ), + params=types.CallToolRequestParams(name="slow_tool", arguments={}), ) ), types.CallToolResult, ) - pytest.fail("Request should have been cancelled") + pytest.fail("Request should have been cancelled") # pragma: no cover except McpError as e: # Expected - request was cancelled assert "Request cancelled" in str(e) ev_cancelled.set() - async with create_connected_server_and_client_session( - make_server() - ) as client_session: + async with create_connected_server_and_client_session(make_server()) as client_session: async with anyio.create_task_group() as tg: tg.start_soon(make_request, client_session) @@ -115,7 +118,6 @@ async def make_request(client_session): await client_session.send_notification( ClientNotification( CancelledNotification( - method="notifications/cancelled", params=CancelledNotificationParams(requestId=request_id), ) ) @@ -124,3 +126,214 @@ async def make_request(client_session): # Give cancellation time to process with anyio.fail_after(1): await ev_cancelled.wait() + + +@pytest.mark.anyio +async def test_response_id_type_mismatch_string_to_int(): + """ + Test that responses with string IDs are correctly matched to requests sent with + integer IDs. + + This handles the case where a server returns "id": "0" (string) but the client + sent "id": 0 (integer). Without ID type normalization, this would cause a timeout. + """ + ev_response_received = anyio.Event() + result_holder: list[types.EmptyResult] = [] + + async with create_client_server_memory_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + server_read, server_write = server_streams + + async def mock_server(): + """Receive a request and respond with a string ID instead of integer.""" + message = await server_read.receive() + assert isinstance(message, SessionMessage) + root = message.message.root + assert isinstance(root, JSONRPCRequest) + # Get the original request ID (which is an integer) + request_id = root.id + assert isinstance(request_id, int), f"Expected int, got {type(request_id)}" + + # Respond with the ID as a string (simulating a buggy server) + response = JSONRPCResponse( + jsonrpc="2.0", + id=str(request_id), # Convert to string to simulate mismatch + result={}, + ) + await server_write.send(SessionMessage(message=JSONRPCMessage(response))) + + async def make_request(client_session: ClientSession): + nonlocal result_holder + # Send a ping request (uses integer ID internally) + result = await client_session.send_ping() + result_holder.append(result) + ev_response_received.set() + + async with ( + anyio.create_task_group() as tg, + ClientSession(read_stream=client_read, write_stream=client_write) as client_session, + ): + tg.start_soon(mock_server) + tg.start_soon(make_request, client_session) + + with anyio.fail_after(2): + await ev_response_received.wait() + + assert len(result_holder) == 1 + assert isinstance(result_holder[0], EmptyResult) + + +@pytest.mark.anyio +async def test_error_response_id_type_mismatch_string_to_int(): + """ + Test that error responses with string IDs are correctly matched to requests + sent with integer IDs. + + This handles the case where a server returns an error with "id": "0" (string) + but the client sent "id": 0 (integer). + """ + ev_error_received = anyio.Event() + error_holder: list[McpError] = [] + + async with create_client_server_memory_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + server_read, server_write = server_streams + + async def mock_server(): + """Receive a request and respond with an error using a string ID.""" + message = await server_read.receive() + assert isinstance(message, SessionMessage) + root = message.message.root + assert isinstance(root, JSONRPCRequest) + request_id = root.id + assert isinstance(request_id, int) + + # Respond with an error, using the ID as a string + error_response = JSONRPCError( + jsonrpc="2.0", + id=str(request_id), # Convert to string to simulate mismatch + error=ErrorData(code=-32600, message="Test error"), + ) + await server_write.send(SessionMessage(message=JSONRPCMessage(error_response))) + + async def make_request(client_session: ClientSession): + nonlocal error_holder + try: + await client_session.send_ping() + pytest.fail("Expected McpError to be raised") # pragma: no cover + except McpError as e: + error_holder.append(e) + ev_error_received.set() + + async with ( + anyio.create_task_group() as tg, + ClientSession(read_stream=client_read, write_stream=client_write) as client_session, + ): + tg.start_soon(mock_server) + tg.start_soon(make_request, client_session) + + with anyio.fail_after(2): + await ev_error_received.wait() + + assert len(error_holder) == 1 + assert "Test error" in str(error_holder[0]) + + +@pytest.mark.anyio +async def test_response_id_non_numeric_string_no_match(): + """ + Test that responses with non-numeric string IDs don't incorrectly match + integer request IDs. + + If a server returns "id": "abc" (non-numeric string), it should not match + a request sent with "id": 0 (integer). + """ + ev_timeout = anyio.Event() + + async with create_client_server_memory_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + server_read, server_write = server_streams + + async def mock_server(): + """Receive a request and respond with a non-numeric string ID.""" + message = await server_read.receive() + assert isinstance(message, SessionMessage) + + # Respond with a non-numeric string ID (should not match) + response = JSONRPCResponse( + jsonrpc="2.0", + id="not_a_number", # Non-numeric string + result={}, + ) + await server_write.send(SessionMessage(message=JSONRPCMessage(response))) + + async def make_request(client_session: ClientSession): + try: + # Use a short timeout since we expect this to fail + await client_session.send_request( + ClientRequest(types.PingRequest()), + types.EmptyResult, + request_read_timeout_seconds=0.5, + ) + pytest.fail("Expected timeout") # pragma: no cover + except McpError as e: + assert "Timed out" in str(e) + ev_timeout.set() + + async with ( + anyio.create_task_group() as tg, + ClientSession(read_stream=client_read, write_stream=client_write) as client_session, + ): + tg.start_soon(mock_server) + tg.start_soon(make_request, client_session) + + with anyio.fail_after(2): + await ev_timeout.wait() + + +@pytest.mark.anyio +async def test_connection_closed(): + """ + Test that pending requests are cancelled when the connection is closed remotely. + """ + + ev_closed = anyio.Event() + ev_response = anyio.Event() + + async with create_client_server_memory_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + server_read, server_write = server_streams + + async def make_request(client_session: ClientSession): + """Send a request in a separate task""" + nonlocal ev_response + try: + # any request will do + await client_session.initialize() + pytest.fail("Request should have errored") # pragma: no cover + except McpError as e: + # Expected - request errored + assert "Connection closed" in str(e) + ev_response.set() + + async def mock_server(): + """Wait for a request, then close the connection""" + nonlocal ev_closed + # Wait for a request + await server_read.receive() + # Close the connection, as if the server exited + server_write.close() + server_read.close() + ev_closed.set() + + async with ( + anyio.create_task_group() as tg, + ClientSession(read_stream=client_read, write_stream=client_write) as client_session, + ): + tg.start_soon(make_request, client_session) + tg.start_soon(mock_server) + + with anyio.fail_after(1): + await ev_closed.wait() + with anyio.fail_after(1): + await ev_response.wait() diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index f5158c3c37..7604450f81 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -1,31 +1,44 @@ +import json import multiprocessing import socket import time from collections.abc import AsyncGenerator, Generator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock, patch import anyio import httpx import pytest import uvicorn +from httpx_sse import ServerSentEvent +from inline_snapshot import snapshot from pydantic import AnyUrl from starlette.applications import Starlette from starlette.requests import Request +from starlette.responses import Response from starlette.routing import Mount, Route +import mcp.client.sse +import mcp.types as types from mcp.client.session import ClientSession -from mcp.client.sse import sse_client +from mcp.client.sse import _extract_session_id_from_endpoint, sse_client from mcp.server import Server from mcp.server.sse import SseServerTransport +from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import McpError from mcp.types import ( EmptyResult, ErrorData, + Implementation, InitializeResult, + JSONRPCResponse, ReadResourceResult, + ServerCapabilities, TextContent, TextResourceContents, Tool, ) +from tests.test_helpers import wait_for_server SERVER_NAME = "test_server_for_SSE" @@ -43,7 +56,7 @@ def server_url(server_port: int) -> str: # Test server implementation -class ServerTest(Server): +class ServerTest(Server): # pragma: no cover def __init__(self): super().__init__(SERVER_NAME) @@ -56,11 +69,7 @@ async def handle_read_resource(uri: AnyUrl) -> str | bytes: await anyio.sleep(2.0) return f"Slow response from {uri.host}" - raise McpError( - error=ErrorData( - code=404, message="OOPS! no resource with that URI was found" - ) - ) + raise McpError(error=ErrorData(code=404, message="OOPS! no resource with that URI was found")) @self.list_tools() async def handle_list_tools() -> list[Tool]: @@ -73,23 +82,24 @@ async def handle_list_tools() -> list[Tool]: ] @self.call_tool() - async def handle_call_tool(name: str, args: dict) -> list[TextContent]: + async def handle_call_tool(name: str, args: dict[str, Any]) -> list[TextContent]: return [TextContent(type="text", text=f"Called {name}")] # Test fixtures -def make_server_app() -> Starlette: +def make_server_app() -> Starlette: # pragma: no cover """Create test Starlette app with SSE transport""" - sse = SseServerTransport("/messages/") + # Configure security with allowed hosts/origins for testing + security_settings = TransportSecuritySettings( + allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"] + ) + sse = SseServerTransport("/messages/", security_settings=security_settings) server = ServerTest() - async def handle_sse(request: Request) -> None: - async with sse.connect_sse( - request.scope, request.receive, request._send - ) as streams: - await server.run( - streams[0], streams[1], server.create_initialization_options() - ) + async def handle_sse(request: Request) -> Response: + async with sse.connect_sse(request.scope, request.receive, request._send) as streams: + await server.run(streams[0], streams[1], server.create_initialization_options()) + return Response() app = Starlette( routes=[ @@ -101,13 +111,9 @@ async def handle_sse(request: Request) -> None: return app -def run_server(server_port: int) -> None: +def run_server(server_port: int) -> None: # pragma: no cover app = make_server_app() - server = uvicorn.Server( - config=uvicorn.Config( - app=app, host="127.0.0.1", port=server_port, log_level="error" - ) - ) + server = uvicorn.Server(config=uvicorn.Config(app=app, host="127.0.0.1", port=server_port, log_level="error")) print(f"starting server on {server_port}") server.run() @@ -119,26 +125,13 @@ def run_server(server_port: int) -> None: @pytest.fixture() def server(server_port: int) -> Generator[None, None, None]: - proc = multiprocessing.Process( - target=run_server, kwargs={"server_port": server_port}, daemon=True - ) + proc = multiprocessing.Process(target=run_server, kwargs={"server_port": server_port}, daemon=True) print("starting process") proc.start() # Wait for server to be running - max_attempts = 20 - attempt = 0 print("waiting for server to start") - while attempt < max_attempts: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect(("127.0.0.1", server_port)) - break - except ConnectionRefusedError: - time.sleep(0.1) - attempt += 1 - else: - raise RuntimeError(f"Server failed to start after {max_attempts} attempts") + wait_for_server(server_port) yield @@ -146,12 +139,12 @@ def server(server_port: int) -> Generator[None, None, None]: # Signal the server to stop proc.kill() proc.join(timeout=2) - if proc.is_alive(): + if proc.is_alive(): # pragma: no cover print("server process failed to terminate") @pytest.fixture() -async def http_client(server, server_url) -> AsyncGenerator[httpx.AsyncClient, None]: +async def http_client(server: None, server_url: str) -> AsyncGenerator[httpx.AsyncClient, None]: """Create test client""" async with httpx.AsyncClient(base_url=server_url) as client: yield client @@ -166,13 +159,10 @@ async def test_raw_sse_connection(http_client: httpx.AsyncClient) -> None: async def connection_test() -> None: async with http_client.stream("GET", "/sse") as response: assert response.status_code == 200 - assert ( - response.headers["content-type"] - == "text/event-stream; charset=utf-8" - ) + assert response.headers["content-type"] == "text/event-stream; charset=utf-8" line_number = 0 - async for line in response.aiter_lines(): + async for line in response.aiter_lines(): # pragma: no branch if line_number == 0: assert line == "event: endpoint" elif line_number == 1: @@ -200,10 +190,59 @@ async def test_sse_client_basic_connection(server: None, server_url: str) -> Non assert isinstance(ping_result, EmptyResult) +@pytest.mark.anyio +async def test_sse_client_on_session_created(server: None, server_url: str) -> None: + captured_session_id: str | None = None + + def on_session_created(session_id: str) -> None: + nonlocal captured_session_id + captured_session_id = session_id + + async with sse_client(server_url + "/sse", on_session_created=on_session_created) as streams: + async with ClientSession(*streams) as session: + result = await session.initialize() + assert isinstance(result, InitializeResult) + + assert captured_session_id is not None + assert len(captured_session_id) > 0 + + +@pytest.mark.parametrize( + "endpoint_url,expected", + [ + ("/messages?sessionId=abc123", "abc123"), + ("/messages?session_id=def456", "def456"), + ("/messages?sessionId=abc&session_id=def", "abc"), + ("/messages?other=value", None), + ("/messages", None), + ("", None), + ], +) +def test_extract_session_id_from_endpoint(endpoint_url: str, expected: str | None) -> None: + assert _extract_session_id_from_endpoint(endpoint_url) == expected + + +@pytest.mark.anyio +async def test_sse_client_on_session_created_not_called_when_no_session_id( + server: None, server_url: str, monkeypatch: pytest.MonkeyPatch +) -> None: + callback_mock = Mock() + + def mock_extract(url: str) -> None: + return None + + monkeypatch.setattr(mcp.client.sse, "_extract_session_id_from_endpoint", mock_extract) + + async with sse_client(server_url + "/sse", on_session_created=callback_mock) as streams: + async with ClientSession(*streams) as session: + result = await session.initialize() + assert isinstance(result, InitializeResult) + + callback_mock.assert_not_called() + + @pytest.fixture -async def initialized_sse_client_session( - server, server_url: str -) -> AsyncGenerator[ClientSession, None]: +async def initialized_sse_client_session(server: None, server_url: str) -> AsyncGenerator[ClientSession, None]: async with sse_client(server_url + "/sse", sse_read_timeout=0.5) as streams: async with ClientSession(*streams) as session: await session.initialize() @@ -231,10 +270,8 @@ async def test_sse_client_exception_handling( @pytest.mark.anyio -@pytest.mark.skip( - "this test highlights a possible bug in SSE read timeout exception handling" -) -async def test_sse_client_timeout( +@pytest.mark.skip("this test highlights a possible bug in SSE read timeout exception handling") +async def test_sse_client_timeout( # pragma: no cover initialized_sse_client_session: ClientSession, ) -> None: session = initialized_sse_client_session @@ -250,3 +287,318 @@ async def test_sse_client_timeout( return pytest.fail("the client should have timed out and returned an error already") + + +def run_mounted_server(server_port: int) -> None: # pragma: no cover + app = make_server_app() + main_app = Starlette(routes=[Mount("/mounted_app", app=app)]) + server = uvicorn.Server(config=uvicorn.Config(app=main_app, host="127.0.0.1", port=server_port, log_level="error")) + print(f"starting server on {server_port}") + server.run() + + # Give server time to start + while not server.started: + print("waiting for server to start") + time.sleep(0.5) + + +@pytest.fixture() +def mounted_server(server_port: int) -> Generator[None, None, None]: + proc = multiprocessing.Process(target=run_mounted_server, kwargs={"server_port": server_port}, daemon=True) + print("starting process") + proc.start() + + # Wait for server to be running + print("waiting for server to start") + wait_for_server(server_port) + + yield + + print("killing server") + # Signal the server to stop + proc.kill() + proc.join(timeout=2) + if proc.is_alive(): # pragma: no cover + print("server process failed to terminate") + + +@pytest.mark.anyio +async def test_sse_client_basic_connection_mounted_app(mounted_server: None, server_url: str) -> None: + async with sse_client(server_url + "/mounted_app/sse") as streams: + async with ClientSession(*streams) as session: + # Test initialization + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == SERVER_NAME + + # Test ping + ping_result = await session.send_ping() + assert isinstance(ping_result, EmptyResult) + + +# Test server with request context that returns headers in the response +class RequestContextServer(Server[object, Request]): # pragma: no cover + def __init__(self): + super().__init__("request_context_server") + + @self.call_tool() + async def handle_call_tool(name: str, args: dict[str, Any]) -> list[TextContent]: + headers_info = {} + context = self.request_context + if context.request: + headers_info = dict(context.request.headers) + + if name == "echo_headers": + return [TextContent(type="text", text=json.dumps(headers_info))] + elif name == "echo_context": + context_data = { + "request_id": args.get("request_id"), + "headers": headers_info, + } + return [TextContent(type="text", text=json.dumps(context_data))] + + return [TextContent(type="text", text=f"Called {name}")] + + @self.list_tools() + async def handle_list_tools() -> list[Tool]: + return [ + Tool( + name="echo_headers", + description="Echoes request headers", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="echo_context", + description="Echoes request context", + inputSchema={ + "type": "object", + "properties": {"request_id": {"type": "string"}}, + "required": ["request_id"], + }, + ), + ] + + +def run_context_server(server_port: int) -> None: # pragma: no cover + """Run a server that captures request context""" + # Configure security with allowed hosts/origins for testing + security_settings = TransportSecuritySettings( + allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"] + ) + sse = SseServerTransport("/messages/", security_settings=security_settings) + context_server = RequestContextServer() + + async def handle_sse(request: Request) -> Response: + async with sse.connect_sse(request.scope, request.receive, request._send) as streams: + await context_server.run(streams[0], streams[1], context_server.create_initialization_options()) + return Response() + + app = Starlette( + routes=[ + Route("/sse", endpoint=handle_sse), + Mount("/messages/", app=sse.handle_post_message), + ] + ) + + server = uvicorn.Server(config=uvicorn.Config(app=app, host="127.0.0.1", port=server_port, log_level="error")) + print(f"starting context server on {server_port}") + server.run() + + +@pytest.fixture() +def context_server(server_port: int) -> Generator[None, None, None]: + """Fixture that provides a server with request context capture""" + proc = multiprocessing.Process(target=run_context_server, kwargs={"server_port": server_port}, daemon=True) + print("starting context server process") + proc.start() + + # Wait for server to be running + print("waiting for context server to start") + wait_for_server(server_port) + + yield + + print("killing context server") + proc.kill() + proc.join(timeout=2) + if proc.is_alive(): # pragma: no cover + print("context server process failed to terminate") + + +@pytest.mark.anyio +async def test_request_context_propagation(context_server: None, server_url: str) -> None: + """Test that request context is properly propagated through SSE transport.""" + # Test with custom headers + custom_headers = { + "Authorization": "Bearer test-token", + "X-Custom-Header": "test-value", + "X-Trace-Id": "trace-123", + } + + async with sse_client(server_url + "/sse", headers=custom_headers) as ( + read_stream, + write_stream, + ): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the session + result = await session.initialize() + assert isinstance(result, InitializeResult) + + # Call the tool that echoes headers back + tool_result = await session.call_tool("echo_headers", {}) + + # Parse the JSON response + + assert len(tool_result.content) == 1 + headers_data = json.loads(tool_result.content[0].text if tool_result.content[0].type == "text" else "{}") + + # Verify headers were propagated + assert headers_data.get("authorization") == "Bearer test-token" + assert headers_data.get("x-custom-header") == "test-value" + assert headers_data.get("x-trace-id") == "trace-123" + + +@pytest.mark.anyio +async def test_request_context_isolation(context_server: None, server_url: str) -> None: + """Test that request contexts are isolated between different SSE clients.""" + contexts: list[dict[str, Any]] = [] + + # Create multiple clients with different headers + for i in range(3): + headers = {"X-Request-Id": f"request-{i}", "X-Custom-Value": f"value-{i}"} + + async with sse_client(server_url + "/sse", headers=headers) as ( + read_stream, + write_stream, + ): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + + # Call the tool that echoes context + tool_result = await session.call_tool("echo_context", {"request_id": f"request-{i}"}) + + assert len(tool_result.content) == 1 + context_data = json.loads( + tool_result.content[0].text if tool_result.content[0].type == "text" else "{}" + ) + contexts.append(context_data) + + # Verify each request had its own context + assert len(contexts) == 3 + for i, ctx in enumerate(contexts): + assert ctx["request_id"] == f"request-{i}" + assert ctx["headers"].get("x-request-id") == f"request-{i}" + assert ctx["headers"].get("x-custom-value") == f"value-{i}" + + +def test_sse_message_id_coercion(): + """Previously, the `RequestId` would coerce a string that looked like an integer into an integer. + + See for more details. + + As per the JSON-RPC 2.0 specification, the id in the response object needs to be the same type as the id in the + request object. In other words, we can't perform the coercion. + + See for more details. + """ + json_message = '{"jsonrpc": "2.0", "id": "123", "method": "ping", "params": null}' + msg = types.JSONRPCMessage.model_validate_json(json_message) + assert msg == snapshot(types.JSONRPCMessage(root=types.JSONRPCRequest(method="ping", jsonrpc="2.0", id="123"))) + + json_message = '{"jsonrpc": "2.0", "id": 123, "method": "ping", "params": null}' + msg = types.JSONRPCMessage.model_validate_json(json_message) + assert msg == snapshot(types.JSONRPCMessage(root=types.JSONRPCRequest(method="ping", jsonrpc="2.0", id=123))) + + +@pytest.mark.parametrize( + "endpoint, expected_result", + [ + # Valid endpoints - should normalize and work + ("/messages/", "/messages/"), + ("messages/", "/messages/"), + ("/", "/"), + # Invalid endpoints - should raise ValueError + ("http://example.com/messages/", ValueError), + ("//example.com/messages/", ValueError), + ("ftp://example.com/messages/", ValueError), + ("/messages/?param=value", ValueError), + ("/messages/#fragment", ValueError), + ], +) +def test_sse_server_transport_endpoint_validation(endpoint: str, expected_result: str | type[Exception]): + """Test that SseServerTransport properly validates and normalizes endpoints.""" + if isinstance(expected_result, type): + # Test invalid endpoints that should raise an exception + with pytest.raises(expected_result, match="is not a relative path.*expecting a relative path"): + SseServerTransport(endpoint) + else: + # Test valid endpoints that should normalize correctly + sse = SseServerTransport(endpoint) + assert sse._endpoint == expected_result + assert sse._endpoint.startswith("/") + + +# ResourceWarning filter: When mocking aconnect_sse, the sse_client's internal task +# group doesn't receive proper cancellation signals, so the sse_reader task's finally +# block (which closes read_stream_writer) doesn't execute. This is a test artifact - +# the actual code path (`if not sse.data: continue`) IS exercised and works correctly. +# Production code with real SSE connections cleans up properly. +@pytest.mark.filterwarnings("ignore::ResourceWarning") +@pytest.mark.anyio +async def test_sse_client_handles_empty_keepalive_pings() -> None: + """Test that SSE client properly handles empty data lines (keep-alive pings). + + Per the MCP spec (Streamable HTTP transport): "The server SHOULD immediately + send an SSE event consisting of an event ID and an empty data field in order + to prime the client to reconnect." + + This test mocks the SSE event stream to include empty "message" events and + verifies the client skips them without crashing. + """ + # Build a proper JSON-RPC response using types (not hardcoded strings) + init_result = InitializeResult( + protocolVersion="2024-11-05", + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="test", version="1.0"), + ) + response = JSONRPCResponse( + jsonrpc="2.0", + id=1, + result=init_result.model_dump(by_alias=True, exclude_none=True), + ) + response_json = response.model_dump_json(by_alias=True, exclude_none=True) + + # Create mock SSE events using httpx_sse's ServerSentEvent + async def mock_aiter_sse() -> AsyncGenerator[ServerSentEvent, None]: + # First: endpoint event + yield ServerSentEvent(event="endpoint", data="/messages/?session_id=abc123") + # Empty data keep-alive ping - this is what we're testing + yield ServerSentEvent(event="message", data="") + # Real JSON-RPC response + yield ServerSentEvent(event="message", data=response_json) + + mock_event_source = MagicMock() + mock_event_source.aiter_sse.return_value = mock_aiter_sse() + mock_event_source.response = MagicMock() + mock_event_source.response.raise_for_status = MagicMock() + + mock_aconnect_sse = MagicMock() + mock_aconnect_sse.__aenter__ = AsyncMock(return_value=mock_event_source) + mock_aconnect_sse.__aexit__ = AsyncMock(return_value=None) + + mock_client = MagicMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.post = AsyncMock(return_value=MagicMock(status_code=200, raise_for_status=MagicMock())) + + with ( + patch("mcp.client.sse.create_mcp_http_client", return_value=mock_client), + patch("mcp.client.sse.aconnect_sse", return_value=mock_aconnect_sse), + ): + async with sse_client("http://test/sse") as (read_stream, _): + # Read the message - should skip the empty one and get the real response + msg = await read_stream.receive() + # If we get here without error, the empty message was skipped successfully + assert not isinstance(msg, Exception) + assert isinstance(msg.message.root, types.JSONRPCResponse) + assert msg.message.root.id == 1 diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py new file mode 100644 index 0000000000..e95c309fbc --- /dev/null +++ b/tests/shared/test_streamable_http.py @@ -0,0 +1,2395 @@ +""" +Tests for the StreamableHTTP server and client transport. + +Contains tests for both server and client sides of the StreamableHTTP transport. +""" + +import json +import multiprocessing +import socket +import time +from collections.abc import Generator +from typing import Any +from unittest.mock import MagicMock + +import anyio +import httpx +import pytest +import requests +import uvicorn +from httpx_sse import ServerSentEvent +from pydantic import AnyUrl +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.routing import Mount + +import mcp.types as types +from mcp.client.session import ClientSession +from mcp.client.streamable_http import ( + StreamableHTTPTransport, + streamable_http_client, + streamablehttp_client, # pyright: ignore[reportDeprecated] +) +from mcp.server import Server +from mcp.server.streamable_http import ( + MCP_PROTOCOL_VERSION_HEADER, + MCP_SESSION_ID_HEADER, + SESSION_ID_PATTERN, + EventCallback, + EventId, + EventMessage, + EventStore, + StreamableHTTPServerTransport, + StreamId, +) +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared._httpx_utils import create_mcp_http_client +from mcp.shared.context import RequestContext +from mcp.shared.exceptions import McpError +from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage +from mcp.shared.session import RequestResponder +from mcp.types import ( + InitializeResult, + JSONRPCMessage, + JSONRPCRequest, + TextContent, + TextResourceContents, + Tool, +) +from tests.test_helpers import wait_for_server + +# Test constants +SERVER_NAME = "test_streamable_http_server" +TEST_SESSION_ID = "test-session-id-12345" +INIT_REQUEST = { + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "clientInfo": {"name": "test-client", "version": "1.0"}, + "protocolVersion": "2025-03-26", + "capabilities": {}, + }, + "id": "init-1", +} + + +# Helper functions +def extract_protocol_version_from_sse(response: requests.Response) -> str: # pragma: no cover + """Extract the negotiated protocol version from an SSE initialization response.""" + assert response.headers.get("Content-Type") == "text/event-stream" + for line in response.text.splitlines(): + if line.startswith("data: "): + init_data = json.loads(line[6:]) + return init_data["result"]["protocolVersion"] + raise ValueError("Could not extract protocol version from SSE response") + + +# Simple in-memory event store for testing +class SimpleEventStore(EventStore): + """Simple in-memory event store for testing.""" + + def __init__(self): + self._events: list[tuple[StreamId, EventId, types.JSONRPCMessage | None]] = [] + self._event_id_counter = 0 + + async def store_event( # pragma: no cover + self, stream_id: StreamId, message: types.JSONRPCMessage | None + ) -> EventId: + """Store an event and return its ID.""" + self._event_id_counter += 1 + event_id = str(self._event_id_counter) + self._events.append((stream_id, event_id, message)) + return event_id + + async def replay_events_after( # pragma: no cover + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + """Replay events after the specified ID.""" + # Find the stream ID of the last event + target_stream_id = None + for stream_id, event_id, _ in self._events: + if event_id == last_event_id: + target_stream_id = stream_id + break + + if target_stream_id is None: + # If event ID not found, return None + return None + + # Convert last_event_id to int for comparison + last_event_id_int = int(last_event_id) + + # Replay only events from the same stream with ID > last_event_id + for stream_id, event_id, message in self._events: + if stream_id == target_stream_id and int(event_id) > last_event_id_int: + # Skip priming events (None message) + if message is not None: + await send_callback(EventMessage(message, event_id)) + + return target_stream_id + + +# Test server implementation that follows MCP protocol +class ServerTest(Server): # pragma: no cover + def __init__(self): + super().__init__(SERVER_NAME) + self._lock = None # Will be initialized in async context + + @self.read_resource() + async def handle_read_resource(uri: AnyUrl) -> str | bytes: + if uri.scheme == "foobar": + return f"Read {uri.host}" + elif uri.scheme == "slow": + # Simulate a slow resource + await anyio.sleep(2.0) + return f"Slow response from {uri.host}" + + raise ValueError(f"Unknown resource: {uri}") + + @self.list_tools() + async def handle_list_tools() -> list[Tool]: + return [ + Tool( + name="test_tool", + description="A test tool", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="test_tool_with_standalone_notification", + description="A test tool that sends a notification", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="long_running_with_checkpoints", + description="A long-running tool that sends periodic notifications", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="test_sampling_tool", + description="A tool that triggers server-side sampling", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="wait_for_lock_with_notification", + description="A tool that sends a notification and waits for lock", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="release_lock", + description="A tool that releases the lock", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="tool_with_stream_close", + description="A tool that closes SSE stream mid-operation", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="tool_with_multiple_notifications_and_close", + description="Tool that sends notification1, closes stream, sends notification2, notification3", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="tool_with_multiple_stream_closes", + description="Tool that closes SSE stream multiple times during execution", + inputSchema={ + "type": "object", + "properties": { + "checkpoints": {"type": "integer", "default": 3}, + "sleep_time": {"type": "number", "default": 0.2}, + }, + }, + ), + Tool( + name="tool_with_standalone_stream_close", + description="Tool that closes standalone GET stream mid-operation", + inputSchema={"type": "object", "properties": {}}, + ), + ] + + @self.call_tool() + async def handle_call_tool(name: str, args: dict[str, Any]) -> list[TextContent]: + ctx = self.request_context + + # When the tool is called, send a notification to test GET stream + if name == "test_tool_with_standalone_notification": + await ctx.session.send_resource_updated(uri=AnyUrl("http://test_resource")) + return [TextContent(type="text", text=f"Called {name}")] + + elif name == "long_running_with_checkpoints": + # Send notifications that are part of the response stream + # This simulates a long-running tool that sends logs + + await ctx.session.send_log_message( + level="info", + data="Tool started", + logger="tool", + related_request_id=ctx.request_id, # need for stream association + ) + + await anyio.sleep(0.1) + + await ctx.session.send_log_message( + level="info", + data="Tool is almost done", + logger="tool", + related_request_id=ctx.request_id, + ) + + return [TextContent(type="text", text="Completed!")] + + elif name == "test_sampling_tool": + # Test sampling by requesting the client to sample a message + sampling_result = await ctx.session.create_message( + messages=[ + types.SamplingMessage( + role="user", + content=types.TextContent(type="text", text="Server needs client sampling"), + ) + ], + max_tokens=100, + related_request_id=ctx.request_id, + ) + + # Return the sampling result in the tool response + # Since we're not passing tools param, result.content is single content + if sampling_result.content.type == "text": + response = sampling_result.content.text + else: + response = str(sampling_result.content) + return [ + TextContent( + type="text", + text=f"Response from sampling: {response}", + ) + ] + + elif name == "wait_for_lock_with_notification": + # Initialize lock if not already done + if self._lock is None: + self._lock = anyio.Event() + + # First send a notification + await ctx.session.send_log_message( + level="info", + data="First notification before lock", + logger="lock_tool", + related_request_id=ctx.request_id, + ) + + # Now wait for the lock to be released + await self._lock.wait() + + # Send second notification after lock is released + await ctx.session.send_log_message( + level="info", + data="Second notification after lock", + logger="lock_tool", + related_request_id=ctx.request_id, + ) + + return [TextContent(type="text", text="Completed")] + + elif name == "release_lock": + assert self._lock is not None, "Lock must be initialized before releasing" + + # Release the lock + self._lock.set() + return [TextContent(type="text", text="Lock released")] + + elif name == "tool_with_stream_close": + # Send notification before closing + await ctx.session.send_log_message( + level="info", + data="Before close", + logger="stream_close_tool", + related_request_id=ctx.request_id, + ) + # Close SSE stream (triggers client reconnect) + assert ctx.close_sse_stream is not None + await ctx.close_sse_stream() + # Continue processing (events stored in event_store) + await anyio.sleep(0.1) + await ctx.session.send_log_message( + level="info", + data="After close", + logger="stream_close_tool", + related_request_id=ctx.request_id, + ) + return [TextContent(type="text", text="Done")] + + elif name == "tool_with_multiple_notifications_and_close": + # Send notification1 + await ctx.session.send_log_message( + level="info", + data="notification1", + logger="multi_notif_tool", + related_request_id=ctx.request_id, + ) + # Close SSE stream + assert ctx.close_sse_stream is not None + await ctx.close_sse_stream() + # Send notification2, notification3 (stored in event_store) + await anyio.sleep(0.1) + await ctx.session.send_log_message( + level="info", + data="notification2", + logger="multi_notif_tool", + related_request_id=ctx.request_id, + ) + await ctx.session.send_log_message( + level="info", + data="notification3", + logger="multi_notif_tool", + related_request_id=ctx.request_id, + ) + return [TextContent(type="text", text="All notifications sent")] + + elif name == "tool_with_multiple_stream_closes": + num_checkpoints = args.get("checkpoints", 3) + sleep_time = args.get("sleep_time", 0.2) + + for i in range(num_checkpoints): + await ctx.session.send_log_message( + level="info", + data=f"checkpoint_{i}", + logger="multi_close_tool", + related_request_id=ctx.request_id, + ) + + if ctx.close_sse_stream: + await ctx.close_sse_stream() + + await anyio.sleep(sleep_time) + + return [TextContent(type="text", text=f"Completed {num_checkpoints} checkpoints")] + + elif name == "tool_with_standalone_stream_close": + # Test for GET stream reconnection + # 1. Send unsolicited notification via GET stream (no related_request_id) + await ctx.session.send_resource_updated(uri=AnyUrl("http://notification_1")) + + # Small delay to ensure notification is flushed before closing + await anyio.sleep(0.1) + + # 2. Close the standalone GET stream + if ctx.close_standalone_sse_stream: + await ctx.close_standalone_sse_stream() + + # 3. Wait for client to reconnect (uses retry_interval from server, default 1000ms) + await anyio.sleep(1.5) + + # 4. Send another notification on the new GET stream connection + await ctx.session.send_resource_updated(uri=AnyUrl("http://notification_2")) + + return [TextContent(type="text", text="Standalone stream close test done")] + + return [TextContent(type="text", text=f"Called {name}")] + + +def create_app( + is_json_response_enabled: bool = False, + event_store: EventStore | None = None, + retry_interval: int | None = None, +) -> Starlette: # pragma: no cover + """Create a Starlette application for testing using the session manager. + + Args: + is_json_response_enabled: If True, use JSON responses instead of SSE streams. + event_store: Optional event store for testing resumability. + retry_interval: Retry interval in milliseconds for SSE polling. + """ + # Create server instance + server = ServerTest() + + # Create the session manager + security_settings = TransportSecuritySettings( + allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"] + ) + session_manager = StreamableHTTPSessionManager( + app=server, + event_store=event_store, + json_response=is_json_response_enabled, + security_settings=security_settings, + retry_interval=retry_interval, + ) + + # Create an ASGI application that uses the session manager + app = Starlette( + debug=True, + routes=[ + Mount("/mcp", app=session_manager.handle_request), + ], + lifespan=lambda app: session_manager.run(), + ) + + return app + + +def run_server( + port: int, + is_json_response_enabled: bool = False, + event_store: EventStore | None = None, + retry_interval: int | None = None, +) -> None: # pragma: no cover + """Run the test server. + + Args: + port: Port to listen on. + is_json_response_enabled: If True, use JSON responses instead of SSE streams. + event_store: Optional event store for testing resumability. + retry_interval: Retry interval in milliseconds for SSE polling. + """ + + app = create_app(is_json_response_enabled, event_store, retry_interval) + # Configure server + config = uvicorn.Config( + app=app, + host="127.0.0.1", + port=port, + log_level="info", + limit_concurrency=10, + timeout_keep_alive=5, + access_log=False, + ) + + # Start the server + server = uvicorn.Server(config=config) + + # This is important to catch exceptions and prevent test hangs + try: + server.run() + except Exception: + import traceback + + traceback.print_exc() + + +# Test fixtures - using same approach as SSE tests +@pytest.fixture +def basic_server_port() -> int: + """Find an available port for the basic server.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def json_server_port() -> int: + """Find an available port for the JSON response server.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def basic_server(basic_server_port: int) -> Generator[None, None, None]: + """Start a basic server.""" + proc = multiprocessing.Process(target=run_server, kwargs={"port": basic_server_port}, daemon=True) + proc.start() + + # Wait for server to be running + wait_for_server(basic_server_port) + + yield + + # Clean up + proc.kill() + proc.join(timeout=2) + + +@pytest.fixture +def event_store() -> SimpleEventStore: + """Create a test event store.""" + return SimpleEventStore() + + +@pytest.fixture +def event_server_port() -> int: + """Find an available port for the event store server.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def event_server( + event_server_port: int, event_store: SimpleEventStore +) -> Generator[tuple[SimpleEventStore, str], None, None]: + """Start a server with event store and retry_interval enabled.""" + proc = multiprocessing.Process( + target=run_server, + kwargs={"port": event_server_port, "event_store": event_store, "retry_interval": 500}, + daemon=True, + ) + proc.start() + + # Wait for server to be running + wait_for_server(event_server_port) + + yield event_store, f"http://127.0.0.1:{event_server_port}" + + # Clean up + proc.kill() + proc.join(timeout=2) + + +@pytest.fixture +def json_response_server(json_server_port: int) -> Generator[None, None, None]: + """Start a server with JSON response enabled.""" + proc = multiprocessing.Process( + target=run_server, + kwargs={"port": json_server_port, "is_json_response_enabled": True}, + daemon=True, + ) + proc.start() + + # Wait for server to be running + wait_for_server(json_server_port) + + yield + + # Clean up + proc.kill() + proc.join(timeout=2) + + +@pytest.fixture +def basic_server_url(basic_server_port: int) -> str: + """Get the URL for the basic test server.""" + return f"http://127.0.0.1:{basic_server_port}" + + +@pytest.fixture +def json_server_url(json_server_port: int) -> str: + """Get the URL for the JSON response test server.""" + return f"http://127.0.0.1:{json_server_port}" + + +# Basic request validation tests +def test_accept_header_validation(basic_server: None, basic_server_url: str): + """Test that Accept header is properly validated.""" + # Test without Accept header + response = requests.post( + f"{basic_server_url}/mcp", + headers={"Content-Type": "application/json"}, + json={"jsonrpc": "2.0", "method": "initialize", "id": 1}, + ) + assert response.status_code == 406 + assert "Not Acceptable" in response.text + + +def test_content_type_validation(basic_server: None, basic_server_url: str): + """Test that Content-Type header is properly validated.""" + # Test with incorrect Content-Type + response = requests.post( + f"{basic_server_url}/mcp", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "text/plain", + }, + data="This is not JSON", + ) + + assert response.status_code == 400 + assert "Invalid Content-Type" in response.text + + +def test_json_validation(basic_server: None, basic_server_url: str): + """Test that JSON content is properly validated.""" + # Test with invalid JSON + response = requests.post( + f"{basic_server_url}/mcp", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + data="this is not valid json", + ) + assert response.status_code == 400 + assert "Parse error" in response.text + + +def test_json_parsing(basic_server: None, basic_server_url: str): + """Test that JSON content is properly parse.""" + # Test with valid JSON but invalid JSON-RPC + response = requests.post( + f"{basic_server_url}/mcp", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json={"foo": "bar"}, + ) + assert response.status_code == 400 + assert "Validation error" in response.text + + +def test_method_not_allowed(basic_server: None, basic_server_url: str): + """Test that unsupported HTTP methods are rejected.""" + # Test with unsupported method (PUT) + response = requests.put( + f"{basic_server_url}/mcp", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json={"jsonrpc": "2.0", "method": "initialize", "id": 1}, + ) + assert response.status_code == 405 + assert "Method Not Allowed" in response.text + + +def test_session_validation(basic_server: None, basic_server_url: str): + """Test session ID validation.""" + # session_id not used directly in this test + + # Test without session ID + response = requests.post( + f"{basic_server_url}/mcp", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json={"jsonrpc": "2.0", "method": "list_tools", "id": 1}, + ) + assert response.status_code == 400 + assert "Missing session ID" in response.text + + +def test_session_id_pattern(): + """Test that SESSION_ID_PATTERN correctly validates session IDs.""" + # Valid session IDs (visible ASCII characters from 0x21 to 0x7E) + valid_session_ids = [ + "test-session-id", + "1234567890", + "session!@#$%^&*()_+-=[]{}|;:,.<>?/", + "~`", + ] + + for session_id in valid_session_ids: + assert SESSION_ID_PATTERN.match(session_id) is not None + # Ensure fullmatch matches too (whole string) + assert SESSION_ID_PATTERN.fullmatch(session_id) is not None + + # Invalid session IDs + invalid_session_ids = [ + "", # Empty string + " test", # Space (0x20) + "test\t", # Tab + "test\n", # Newline + "test\r", # Carriage return + "test" + chr(0x7F), # DEL character + "test" + chr(0x80), # Extended ASCII + "test" + chr(0x00), # Null character + "test" + chr(0x20), # Space (0x20) + ] + + for session_id in invalid_session_ids: + # For invalid IDs, either match will fail or fullmatch will fail + if SESSION_ID_PATTERN.match(session_id) is not None: + # If match succeeds, fullmatch should fail (partial match case) + assert SESSION_ID_PATTERN.fullmatch(session_id) is None + + +def test_streamable_http_transport_init_validation(): + """Test that StreamableHTTPServerTransport validates session ID on init.""" + # Valid session ID should initialize without errors + valid_transport = StreamableHTTPServerTransport(mcp_session_id="valid-id") + assert valid_transport.mcp_session_id == "valid-id" + + # None should be accepted + none_transport = StreamableHTTPServerTransport(mcp_session_id=None) + assert none_transport.mcp_session_id is None + + # Invalid session ID should raise ValueError + with pytest.raises(ValueError) as excinfo: + StreamableHTTPServerTransport(mcp_session_id="invalid id with space") + assert "Session ID must only contain visible ASCII characters" in str(excinfo.value) + + # Test with control characters + with pytest.raises(ValueError): + StreamableHTTPServerTransport(mcp_session_id="test\nid") + + with pytest.raises(ValueError): + StreamableHTTPServerTransport(mcp_session_id="test\n") + + +def test_session_termination(basic_server: None, basic_server_url: str): + """Test session termination via DELETE and subsequent request handling.""" + response = requests.post( + f"{basic_server_url}/mcp", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert response.status_code == 200 + + # Extract negotiated protocol version from SSE response + negotiated_version = extract_protocol_version_from_sse(response) + + # Now terminate the session + session_id = response.headers.get(MCP_SESSION_ID_HEADER) + response = requests.delete( + f"{basic_server_url}/mcp", + headers={ + MCP_SESSION_ID_HEADER: session_id, + MCP_PROTOCOL_VERSION_HEADER: negotiated_version, + }, + ) + assert response.status_code == 200 + + # Try to use the terminated session + response = requests.post( + f"{basic_server_url}/mcp", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + MCP_SESSION_ID_HEADER: session_id, + }, + json={"jsonrpc": "2.0", "method": "ping", "id": 2}, + ) + assert response.status_code == 404 + assert "Session has been terminated" in response.text + + +def test_response(basic_server: None, basic_server_url: str): + """Test response handling for a valid request.""" + mcp_url = f"{basic_server_url}/mcp" + response = requests.post( + mcp_url, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert response.status_code == 200 + + # Extract negotiated protocol version from SSE response + negotiated_version = extract_protocol_version_from_sse(response) + + # Now get the session ID + session_id = response.headers.get(MCP_SESSION_ID_HEADER) + + # Try to use the session with proper headers + tools_response = requests.post( + mcp_url, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + MCP_SESSION_ID_HEADER: session_id, # Use the session ID we got earlier + MCP_PROTOCOL_VERSION_HEADER: negotiated_version, + }, + json={"jsonrpc": "2.0", "method": "tools/list", "id": "tools-1"}, + stream=True, + ) + assert tools_response.status_code == 200 + assert tools_response.headers.get("Content-Type") == "text/event-stream" + + +def test_json_response(json_response_server: None, json_server_url: str): + """Test response handling when is_json_response_enabled is True.""" + mcp_url = f"{json_server_url}/mcp" + response = requests.post( + mcp_url, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert response.status_code == 200 + assert response.headers.get("Content-Type") == "application/json" + + +def test_json_response_accept_json_only(json_response_server: None, json_server_url: str): + """Test that json_response servers only require application/json in Accept header.""" + mcp_url = f"{json_server_url}/mcp" + response = requests.post( + mcp_url, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert response.status_code == 200 + assert response.headers.get("Content-Type") == "application/json" + + +def test_json_response_missing_accept_header(json_response_server: None, json_server_url: str): + """Test that json_response servers reject requests without Accept header.""" + mcp_url = f"{json_server_url}/mcp" + response = requests.post( + mcp_url, + headers={ + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert response.status_code == 406 + assert "Not Acceptable" in response.text + + +def test_json_response_incorrect_accept_header(json_response_server: None, json_server_url: str): + """Test that json_response servers reject requests with incorrect Accept header.""" + mcp_url = f"{json_server_url}/mcp" + # Test with only text/event-stream (wrong for JSON server) + response = requests.post( + mcp_url, + headers={ + "Accept": "text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert response.status_code == 406 + assert "Not Acceptable" in response.text + + +def test_get_sse_stream(basic_server: None, basic_server_url: str): + """Test establishing an SSE stream via GET request.""" + # First, we need to initialize a session + mcp_url = f"{basic_server_url}/mcp" + init_response = requests.post( + mcp_url, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert init_response.status_code == 200 + + # Get the session ID + session_id = init_response.headers.get(MCP_SESSION_ID_HEADER) + assert session_id is not None + + # Extract negotiated protocol version from SSE response + init_data = None + assert init_response.headers.get("Content-Type") == "text/event-stream" + for line in init_response.text.splitlines(): # pragma: no branch + if line.startswith("data: "): # pragma: no cover + init_data = json.loads(line[6:]) + break + assert init_data is not None + negotiated_version = init_data["result"]["protocolVersion"] + + # Now attempt to establish an SSE stream via GET + get_response = requests.get( + mcp_url, + headers={ + "Accept": "text/event-stream", + MCP_SESSION_ID_HEADER: session_id, + MCP_PROTOCOL_VERSION_HEADER: negotiated_version, + }, + stream=True, + ) + + # Verify we got a successful response with the right content type + assert get_response.status_code == 200 + assert get_response.headers.get("Content-Type") == "text/event-stream" + + # Test that a second GET request gets rejected (only one stream allowed) + second_get = requests.get( + mcp_url, + headers={ + "Accept": "text/event-stream", + MCP_SESSION_ID_HEADER: session_id, + MCP_PROTOCOL_VERSION_HEADER: negotiated_version, + }, + stream=True, + ) + + # Should get CONFLICT (409) since there's already a stream + # Note: This might fail if the first stream fully closed before this runs, + # but generally it should work in the test environment where it runs quickly + assert second_get.status_code == 409 + + +def test_get_validation(basic_server: None, basic_server_url: str): + """Test validation for GET requests.""" + # First, we need to initialize a session + mcp_url = f"{basic_server_url}/mcp" + init_response = requests.post( + mcp_url, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert init_response.status_code == 200 + + # Get the session ID + session_id = init_response.headers.get(MCP_SESSION_ID_HEADER) + assert session_id is not None + + # Extract negotiated protocol version from SSE response + init_data = None + assert init_response.headers.get("Content-Type") == "text/event-stream" + for line in init_response.text.splitlines(): # pragma: no branch + if line.startswith("data: "): # pragma: no cover + init_data = json.loads(line[6:]) + break + assert init_data is not None + negotiated_version = init_data["result"]["protocolVersion"] + + # Test without Accept header + response = requests.get( + mcp_url, + headers={ + MCP_SESSION_ID_HEADER: session_id, + MCP_PROTOCOL_VERSION_HEADER: negotiated_version, + }, + stream=True, + ) + assert response.status_code == 406 + assert "Not Acceptable" in response.text + + # Test with wrong Accept header + response = requests.get( + mcp_url, + headers={ + "Accept": "application/json", + MCP_SESSION_ID_HEADER: session_id, + MCP_PROTOCOL_VERSION_HEADER: negotiated_version, + }, + ) + assert response.status_code == 406 + assert "Not Acceptable" in response.text + + +# Client-specific fixtures +@pytest.fixture +async def http_client(basic_server: None, basic_server_url: str): # pragma: no cover + """Create test client matching the SSE test pattern.""" + async with httpx.AsyncClient(base_url=basic_server_url) as client: + yield client + + +@pytest.fixture +async def initialized_client_session(basic_server: None, basic_server_url: str): + """Create initialized StreamableHTTP client session.""" + async with streamable_http_client(f"{basic_server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession( + read_stream, + write_stream, + ) as session: + await session.initialize() + yield session + + +@pytest.mark.anyio +async def test_streamable_http_client_basic_connection(basic_server: None, basic_server_url: str): + """Test basic client connection with initialization.""" + async with streamable_http_client(f"{basic_server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession( + read_stream, + write_stream, + ) as session: + # Test initialization + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == SERVER_NAME + + +@pytest.mark.anyio +async def test_streamable_http_client_resource_read(initialized_client_session: ClientSession): + """Test client resource read functionality.""" + response = await initialized_client_session.read_resource(uri=AnyUrl("foobar://test-resource")) + assert len(response.contents) == 1 + assert response.contents[0].uri == AnyUrl("foobar://test-resource") + assert isinstance(response.contents[0], TextResourceContents) + assert response.contents[0].text == "Read test-resource" + + +@pytest.mark.anyio +async def test_streamable_http_client_tool_invocation(initialized_client_session: ClientSession): + """Test client tool invocation.""" + # First list tools + tools = await initialized_client_session.list_tools() + assert len(tools.tools) == 10 + assert tools.tools[0].name == "test_tool" + + # Call the tool + result = await initialized_client_session.call_tool("test_tool", {}) + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert result.content[0].text == "Called test_tool" + + +@pytest.mark.anyio +async def test_streamable_http_client_error_handling(initialized_client_session: ClientSession): + """Test error handling in client.""" + with pytest.raises(McpError) as exc_info: + await initialized_client_session.read_resource(uri=AnyUrl("unknown://test-error")) + assert exc_info.value.error.code == 0 + assert "Unknown resource: unknown://test-error" in exc_info.value.error.message + + +@pytest.mark.anyio +async def test_streamable_http_client_session_persistence(basic_server: None, basic_server_url: str): + """Test that session ID persists across requests.""" + async with streamable_http_client(f"{basic_server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession( + read_stream, + write_stream, + ) as session: + # Initialize the session + result = await session.initialize() + assert isinstance(result, InitializeResult) + + # Make multiple requests to verify session persistence + tools = await session.list_tools() + assert len(tools.tools) == 10 + + # Read a resource + resource = await session.read_resource(uri=AnyUrl("foobar://test-persist")) + assert isinstance(resource.contents[0], TextResourceContents) is True + content = resource.contents[0] + assert isinstance(content, TextResourceContents) + assert content.text == "Read test-persist" + + +@pytest.mark.anyio +async def test_streamable_http_client_json_response(json_response_server: None, json_server_url: str): + """Test client with JSON response mode.""" + async with streamable_http_client(f"{json_server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession( + read_stream, + write_stream, + ) as session: + # Initialize the session + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == SERVER_NAME + + # Check tool listing + tools = await session.list_tools() + assert len(tools.tools) == 10 + + # Call a tool and verify JSON response handling + result = await session.call_tool("test_tool", {}) + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert result.content[0].text == "Called test_tool" + + +@pytest.mark.anyio +async def test_streamable_http_client_get_stream(basic_server: None, basic_server_url: str): + """Test GET stream functionality for server-initiated messages.""" + import mcp.types as types + + notifications_received: list[types.ServerNotification] = [] + + # Define message handler to capture notifications + async def message_handler( # pragma: no branch + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: + if isinstance(message, types.ServerNotification): # pragma: no branch + notifications_received.append(message) + + async with streamable_http_client(f"{basic_server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: + # Initialize the session - this triggers the GET stream setup + result = await session.initialize() + assert isinstance(result, InitializeResult) + + # Call the special tool that sends a notification + await session.call_tool("test_tool_with_standalone_notification", {}) + + # Verify we received the notification + assert len(notifications_received) > 0 + + # Verify the notification is a ResourceUpdatedNotification + resource_update_found = False + for notif in notifications_received: + if isinstance(notif.root, types.ResourceUpdatedNotification): # pragma: no branch + assert str(notif.root.params.uri) == "http://test_resource/" + resource_update_found = True + + assert resource_update_found, "ResourceUpdatedNotification not received via GET stream" + + +@pytest.mark.anyio +async def test_streamable_http_client_session_termination(basic_server: None, basic_server_url: str): + """Test client session termination functionality.""" + + captured_session_id = None + + # Create the streamable_http_client with a custom httpx client to capture headers + async with streamable_http_client(f"{basic_server_url}/mcp") as ( + read_stream, + write_stream, + get_session_id, + ): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the session + result = await session.initialize() + assert isinstance(result, InitializeResult) + captured_session_id = get_session_id() + assert captured_session_id is not None + + # Make a request to confirm session is working + tools = await session.list_tools() + assert len(tools.tools) == 10 + + headers: dict[str, str] = {} # pragma: no cover + if captured_session_id: # pragma: no cover + headers[MCP_SESSION_ID_HEADER] = captured_session_id + + async with create_mcp_http_client(headers=headers) as httpx_client: + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + # Attempt to make a request after termination + with pytest.raises( # pragma: no branch + McpError, + match="Session terminated", + ): + await session.list_tools() + + +@pytest.mark.anyio +async def test_streamable_http_client_session_termination_204( + basic_server: None, basic_server_url: str, monkeypatch: pytest.MonkeyPatch +): + """Test client session termination functionality with a 204 response. + + This test patches the httpx client to return a 204 response for DELETEs. + """ + + # Save the original delete method to restore later + original_delete = httpx.AsyncClient.delete + + # Mock the client's delete method to return a 204 + async def mock_delete(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> httpx.Response: + # Call the original method to get the real response + response = await original_delete(self, *args, **kwargs) + + # Create a new response with 204 status code but same headers + mocked_response = httpx.Response( + 204, + headers=response.headers, + content=response.content, + request=response.request, + ) + return mocked_response + + # Apply the patch to the httpx client + monkeypatch.setattr(httpx.AsyncClient, "delete", mock_delete) + + captured_session_id = None + + # Create the streamable_http_client with a custom httpx client to capture headers + async with streamable_http_client(f"{basic_server_url}/mcp") as ( + read_stream, + write_stream, + get_session_id, + ): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the session + result = await session.initialize() + assert isinstance(result, InitializeResult) + captured_session_id = get_session_id() + assert captured_session_id is not None + + # Make a request to confirm session is working + tools = await session.list_tools() + assert len(tools.tools) == 10 + + headers: dict[str, str] = {} # pragma: no cover + if captured_session_id: # pragma: no cover + headers[MCP_SESSION_ID_HEADER] = captured_session_id + + async with create_mcp_http_client(headers=headers) as httpx_client: + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + # Attempt to make a request after termination + with pytest.raises( # pragma: no branch + McpError, + match="Session terminated", + ): + await session.list_tools() + + +@pytest.mark.anyio +async def test_streamable_http_client_resumption(event_server: tuple[SimpleEventStore, str]): + """Test client session resumption using sync primitives for reliable coordination.""" + _, server_url = event_server + + # Variables to track the state + captured_session_id = None + captured_resumption_token = None + captured_notifications: list[types.ServerNotification] = [] + captured_protocol_version = None + first_notification_received = False + + async def message_handler( # pragma: no branch + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: + if isinstance(message, types.ServerNotification): # pragma: no branch + captured_notifications.append(message) + # Look for our first notification + if isinstance(message.root, types.LoggingMessageNotification): # pragma: no branch + if message.root.params.data == "First notification before lock": + nonlocal first_notification_received + first_notification_received = True + + async def on_resumption_token_update(token: str) -> None: + nonlocal captured_resumption_token + captured_resumption_token = token + + # First, start the client session and begin the tool that waits on lock + async with streamable_http_client(f"{server_url}/mcp", terminate_on_close=False) as ( + read_stream, + write_stream, + get_session_id, + ): + async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: + # Initialize the session + result = await session.initialize() + assert isinstance(result, InitializeResult) + captured_session_id = get_session_id() + assert captured_session_id is not None + # Capture the negotiated protocol version + captured_protocol_version = result.protocolVersion + + # Start the tool that will wait on lock in a task + async with anyio.create_task_group() as tg: + + async def run_tool(): + metadata = ClientMessageMetadata( + on_resumption_token_update=on_resumption_token_update, + ) + await session.send_request( + types.ClientRequest( + types.CallToolRequest( + params=types.CallToolRequestParams( + name="wait_for_lock_with_notification", arguments={} + ), + ) + ), + types.CallToolResult, + metadata=metadata, + ) + + tg.start_soon(run_tool) + + # Wait for the first notification and resumption token + while not first_notification_received or not captured_resumption_token: + await anyio.sleep(0.1) + + # Kill the client session while tool is waiting on lock + tg.cancel_scope.cancel() + + # Verify we received exactly one notification + assert len(captured_notifications) == 1 # pragma: no cover + assert isinstance(captured_notifications[0].root, types.LoggingMessageNotification) # pragma: no cover + assert captured_notifications[0].root.params.data == "First notification before lock" # pragma: no cover + + # Clear notifications for the second phase + captured_notifications = [] # pragma: no cover + + # Now resume the session with the same mcp-session-id and protocol version + headers: dict[str, Any] = {} # pragma: no cover + if captured_session_id: # pragma: no cover + headers[MCP_SESSION_ID_HEADER] = captured_session_id + if captured_protocol_version: # pragma: no cover + headers[MCP_PROTOCOL_VERSION_HEADER] = captured_protocol_version + + async with create_mcp_http_client(headers=headers) as httpx_client: + async with streamable_http_client(f"{server_url}/mcp", http_client=httpx_client) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: + result = await session.send_request( + types.ClientRequest( + types.CallToolRequest( + params=types.CallToolRequestParams(name="release_lock", arguments={}), + ) + ), + types.CallToolResult, + ) + metadata = ClientMessageMetadata( + resumption_token=captured_resumption_token, + ) + + result = await session.send_request( + types.ClientRequest( + types.CallToolRequest( + params=types.CallToolRequestParams(name="wait_for_lock_with_notification", arguments={}), + ) + ), + types.CallToolResult, + metadata=metadata, + ) + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert result.content[0].text == "Completed" + + # We should have received the remaining notifications + assert len(captured_notifications) == 1 + + assert isinstance(captured_notifications[0].root, types.LoggingMessageNotification) # pragma: no cover + assert captured_notifications[0].root.params.data == "Second notification after lock" # pragma: no cover + + +@pytest.mark.anyio +async def test_streamablehttp_server_sampling(basic_server: None, basic_server_url: str): + """Test server-initiated sampling request through streamable HTTP transport.""" + # Variable to track if sampling callback was invoked + sampling_callback_invoked = False + captured_message_params = None + + # Define sampling callback that returns a mock response + async def sampling_callback( + context: RequestContext[ClientSession, Any], + params: types.CreateMessageRequestParams, + ) -> types.CreateMessageResult: + nonlocal sampling_callback_invoked, captured_message_params + sampling_callback_invoked = True + captured_message_params = params + msg_content = params.messages[0].content_as_list[0] + message_received = msg_content.text if msg_content.type == "text" else None + + return types.CreateMessageResult( + role="assistant", + content=types.TextContent( + type="text", + text=f"Received message from server: {message_received}", + ), + model="test-model", + stopReason="endTurn", + ) + + # Create client with sampling callback + async with streamable_http_client(f"{basic_server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession( + read_stream, + write_stream, + sampling_callback=sampling_callback, + ) as session: + # Initialize the session + result = await session.initialize() + assert isinstance(result, InitializeResult) + + # Call the tool that triggers server-side sampling + tool_result = await session.call_tool("test_sampling_tool", {}) + + # Verify the tool result contains the expected content + assert len(tool_result.content) == 1 + assert tool_result.content[0].type == "text" + assert "Response from sampling: Received message from server" in tool_result.content[0].text + + # Verify sampling callback was invoked + assert sampling_callback_invoked + assert captured_message_params is not None + assert len(captured_message_params.messages) == 1 + assert captured_message_params.messages[0].content.text == "Server needs client sampling" + + +# Context-aware server implementation for testing request context propagation +class ContextAwareServerTest(Server): # pragma: no cover + def __init__(self): + super().__init__("ContextAwareServer") + + @self.list_tools() + async def handle_list_tools() -> list[Tool]: + return [ + Tool( + name="echo_headers", + description="Echo request headers from context", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="echo_context", + description="Echo request context with custom data", + inputSchema={ + "type": "object", + "properties": { + "request_id": {"type": "string"}, + }, + "required": ["request_id"], + }, + ), + ] + + @self.call_tool() + async def handle_call_tool(name: str, args: dict[str, Any]) -> list[TextContent]: + ctx = self.request_context + + if name == "echo_headers": + # Access the request object from context + headers_info = {} + if ctx.request and isinstance(ctx.request, Request): + headers_info = dict(ctx.request.headers) + return [TextContent(type="text", text=json.dumps(headers_info))] + + elif name == "echo_context": + # Return full context information + context_data: dict[str, Any] = { + "request_id": args.get("request_id"), + "headers": {}, + "method": None, + "path": None, + } + if ctx.request and isinstance(ctx.request, Request): + request = ctx.request + context_data["headers"] = dict(request.headers) + context_data["method"] = request.method + context_data["path"] = request.url.path + return [ + TextContent( + type="text", + text=json.dumps(context_data), + ) + ] + + return [TextContent(type="text", text=f"Unknown tool: {name}")] + + +# Server runner for context-aware testing +def run_context_aware_server(port: int): # pragma: no cover + """Run the context-aware test server.""" + server = ContextAwareServerTest() + + session_manager = StreamableHTTPSessionManager( + app=server, + event_store=None, + json_response=False, + ) + + app = Starlette( + debug=True, + routes=[ + Mount("/mcp", app=session_manager.handle_request), + ], + lifespan=lambda app: session_manager.run(), + ) + + server_instance = uvicorn.Server( + config=uvicorn.Config( + app=app, + host="127.0.0.1", + port=port, + log_level="error", + ) + ) + server_instance.run() + + +@pytest.fixture +def context_aware_server(basic_server_port: int) -> Generator[None, None, None]: + """Start the context-aware server in a separate process.""" + proc = multiprocessing.Process(target=run_context_aware_server, args=(basic_server_port,), daemon=True) + proc.start() + + # Wait for server to be running + wait_for_server(basic_server_port) + + yield + + proc.kill() + proc.join(timeout=2) + if proc.is_alive(): # pragma: no cover + print("Context-aware server process failed to terminate") + + +@pytest.mark.anyio +async def test_streamablehttp_request_context_propagation(context_aware_server: None, basic_server_url: str) -> None: + """Test that request context is properly propagated through StreamableHTTP.""" + custom_headers = { + "Authorization": "Bearer test-token", + "X-Custom-Header": "test-value", + "X-Trace-Id": "trace-123", + } + + async with create_mcp_http_client(headers=custom_headers) as httpx_client: + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "ContextAwareServer" + + # Call the tool that echoes headers back + tool_result = await session.call_tool("echo_headers", {}) + + # Parse the JSON response + assert len(tool_result.content) == 1 + assert isinstance(tool_result.content[0], TextContent) + headers_data = json.loads(tool_result.content[0].text) + + # Verify headers were propagated + assert headers_data.get("authorization") == "Bearer test-token" + assert headers_data.get("x-custom-header") == "test-value" + assert headers_data.get("x-trace-id") == "trace-123" + + +@pytest.mark.anyio +async def test_streamablehttp_request_context_isolation(context_aware_server: None, basic_server_url: str) -> None: + """Test that request contexts are isolated between StreamableHTTP clients.""" + contexts: list[dict[str, Any]] = [] + + # Create multiple clients with different headers + for i in range(3): + headers = { + "X-Request-Id": f"request-{i}", + "X-Custom-Value": f"value-{i}", + "Authorization": f"Bearer token-{i}", + } + + async with create_mcp_http_client(headers=headers) as httpx_client: + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + await session.initialize() + + # Call the tool that echoes context + tool_result = await session.call_tool("echo_context", {"request_id": f"request-{i}"}) + + assert len(tool_result.content) == 1 + assert isinstance(tool_result.content[0], TextContent) + context_data = json.loads(tool_result.content[0].text) + contexts.append(context_data) + + # Verify each request had its own context + assert len(contexts) == 3 # pragma: no cover + for i, ctx in enumerate(contexts): # pragma: no cover + assert ctx["request_id"] == f"request-{i}" + assert ctx["headers"].get("x-request-id") == f"request-{i}" + assert ctx["headers"].get("x-custom-value") == f"value-{i}" + assert ctx["headers"].get("authorization") == f"Bearer token-{i}" + + +@pytest.mark.anyio +async def test_client_includes_protocol_version_header_after_init(context_aware_server: None, basic_server_url: str): + """Test that client includes mcp-protocol-version header after initialization.""" + async with streamable_http_client(f"{basic_server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + # Initialize and get the negotiated version + init_result = await session.initialize() + negotiated_version = init_result.protocolVersion + + # Call a tool that echoes headers to verify the header is present + tool_result = await session.call_tool("echo_headers", {}) + + assert len(tool_result.content) == 1 + assert isinstance(tool_result.content[0], TextContent) + headers_data = json.loads(tool_result.content[0].text) + + # Verify protocol version header is present + assert "mcp-protocol-version" in headers_data + assert headers_data[MCP_PROTOCOL_VERSION_HEADER] == negotiated_version + + +def test_server_validates_protocol_version_header(basic_server: None, basic_server_url: str): + """Test that server returns 400 Bad Request version if header unsupported or invalid.""" + # First initialize a session to get a valid session ID + init_response = requests.post( + f"{basic_server_url}/mcp", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert init_response.status_code == 200 + session_id = init_response.headers.get(MCP_SESSION_ID_HEADER) + + # Test request with invalid protocol version (should fail) + response = requests.post( + f"{basic_server_url}/mcp", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + MCP_SESSION_ID_HEADER: session_id, + MCP_PROTOCOL_VERSION_HEADER: "invalid-version", + }, + json={"jsonrpc": "2.0", "method": "tools/list", "id": "test-2"}, + ) + assert response.status_code == 400 + assert MCP_PROTOCOL_VERSION_HEADER in response.text or "protocol version" in response.text.lower() + + # Test request with unsupported protocol version (should fail) + response = requests.post( + f"{basic_server_url}/mcp", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + MCP_SESSION_ID_HEADER: session_id, + MCP_PROTOCOL_VERSION_HEADER: "1999-01-01", # Very old unsupported version + }, + json={"jsonrpc": "2.0", "method": "tools/list", "id": "test-3"}, + ) + assert response.status_code == 400 + assert MCP_PROTOCOL_VERSION_HEADER in response.text or "protocol version" in response.text.lower() + + # Test request with valid protocol version (should succeed) + negotiated_version = extract_protocol_version_from_sse(init_response) + + response = requests.post( + f"{basic_server_url}/mcp", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + MCP_SESSION_ID_HEADER: session_id, + MCP_PROTOCOL_VERSION_HEADER: negotiated_version, + }, + json={"jsonrpc": "2.0", "method": "tools/list", "id": "test-4"}, + ) + assert response.status_code == 200 + + +def test_server_backwards_compatibility_no_protocol_version(basic_server: None, basic_server_url: str): + """Test server accepts requests without protocol version header.""" + # First initialize a session to get a valid session ID + init_response = requests.post( + f"{basic_server_url}/mcp", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + json=INIT_REQUEST, + ) + assert init_response.status_code == 200 + session_id = init_response.headers.get(MCP_SESSION_ID_HEADER) + + # Test request without mcp-protocol-version header (backwards compatibility) + response = requests.post( + f"{basic_server_url}/mcp", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + MCP_SESSION_ID_HEADER: session_id, + }, + json={"jsonrpc": "2.0", "method": "tools/list", "id": "test-backwards-compat"}, + stream=True, + ) + assert response.status_code == 200 # Should succeed for backwards compatibility + assert response.headers.get("Content-Type") == "text/event-stream" + + +@pytest.mark.anyio +async def test_client_crash_handled(basic_server: None, basic_server_url: str): + """Test that cases where the client crashes are handled gracefully.""" + + # Simulate bad client that crashes after init + async def bad_client(): + """Client that triggers ClosedResourceError""" + async with streamable_http_client(f"{basic_server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + raise Exception("client crash") + + # Run bad client a few times to trigger the crash + for _ in range(3): + try: + await bad_client() + except Exception: + pass + await anyio.sleep(0.1) + + # Try a good client, it should still be able to connect and list tools + async with streamable_http_client(f"{basic_server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + result = await session.initialize() + assert isinstance(result, InitializeResult) + tools = await session.list_tools() + assert tools.tools + + +@pytest.mark.anyio +async def test_handle_sse_event_skips_empty_data(): + """Test that _handle_sse_event skips empty SSE data (keep-alive pings).""" + transport = StreamableHTTPTransport(url="http://localhost:8000/mcp") + + # Create a mock SSE event with empty data (keep-alive ping) + mock_sse = ServerSentEvent(event="message", data="", id=None, retry=None) + + # Create a mock stream writer + write_stream, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](1) + + try: + # Call _handle_sse_event with empty data - should return False and not raise + result = await transport._handle_sse_event(mock_sse, write_stream) + + # Should return False (not complete) for empty data + assert result is False + + # Nothing should have been written to the stream + # Check buffer is empty (statistics().current_buffer_used returns buffer size) + assert write_stream.statistics().current_buffer_used == 0 + finally: + await write_stream.aclose() + await read_stream.aclose() + + +@pytest.mark.anyio +async def test_priming_event_not_sent_for_old_protocol_version(): + """Test that _maybe_send_priming_event skips for old protocol versions (backwards compat).""" + # Create a transport with an event store + transport = StreamableHTTPServerTransport( + "/mcp", + event_store=SimpleEventStore(), + ) + + # Create a mock stream writer + write_stream, read_stream = anyio.create_memory_object_stream[dict[str, Any]](1) + + try: + # Call _maybe_send_priming_event with OLD protocol version - should NOT send + await transport._maybe_send_priming_event("test-request-id", write_stream, "2025-06-18") + + # Nothing should have been written to the stream + assert write_stream.statistics().current_buffer_used == 0 + + # Now test with NEW protocol version - should send + await transport._maybe_send_priming_event("test-request-id-2", write_stream, "2025-11-25") + + # Should have written a priming event + assert write_stream.statistics().current_buffer_used == 1 + finally: + await write_stream.aclose() + await read_stream.aclose() + + +@pytest.mark.anyio +async def test_priming_event_not_sent_without_event_store(): + """Test that _maybe_send_priming_event returns early when no event_store is configured.""" + # Create a transport WITHOUT an event store + transport = StreamableHTTPServerTransport("/mcp") + + # Create a mock stream writer + write_stream, read_stream = anyio.create_memory_object_stream[dict[str, Any]](1) + + try: + # Call _maybe_send_priming_event - should return early without sending + await transport._maybe_send_priming_event("test-request-id", write_stream, "2025-11-25") + + # Nothing should have been written to the stream + assert write_stream.statistics().current_buffer_used == 0 + finally: + await write_stream.aclose() + await read_stream.aclose() + + +@pytest.mark.anyio +async def test_priming_event_includes_retry_interval(): + """Test that _maybe_send_priming_event includes retry field when retry_interval is set.""" + # Create a transport with an event store AND retry_interval + transport = StreamableHTTPServerTransport( + "/mcp", + event_store=SimpleEventStore(), + retry_interval=5000, + ) + + # Create a mock stream writer + write_stream, read_stream = anyio.create_memory_object_stream[dict[str, Any]](1) + + try: + # Call _maybe_send_priming_event with new protocol version + await transport._maybe_send_priming_event("test-request-id", write_stream, "2025-11-25") + + # Should have written a priming event with retry field + assert write_stream.statistics().current_buffer_used == 1 + + # Read the event and verify it has retry field + event = await read_stream.receive() + assert "retry" in event + assert event["retry"] == 5000 + finally: + await write_stream.aclose() + await read_stream.aclose() + + +@pytest.mark.anyio +async def test_close_sse_stream_callback_not_provided_for_old_protocol_version(): + """Test that close_sse_stream callbacks are NOT provided for old protocol versions.""" + # Create a transport with an event store + transport = StreamableHTTPServerTransport( + "/mcp", + event_store=SimpleEventStore(), + ) + + # Create a mock message and request + mock_message = JSONRPCMessage(root=JSONRPCRequest(jsonrpc="2.0", id="test-1", method="tools/list")) + mock_request = MagicMock() + + # Call _create_session_message with OLD protocol version + session_msg = transport._create_session_message(mock_message, mock_request, "test-request-id", "2025-06-18") + + # Callbacks should NOT be provided for old protocol version + assert session_msg.metadata is not None + assert isinstance(session_msg.metadata, ServerMessageMetadata) + assert session_msg.metadata.close_sse_stream is None + assert session_msg.metadata.close_standalone_sse_stream is None + + # Now test with NEW protocol version - should provide callbacks + session_msg_new = transport._create_session_message(mock_message, mock_request, "test-request-id-2", "2025-11-25") + + # Callbacks SHOULD be provided for new protocol version + assert session_msg_new.metadata is not None + assert isinstance(session_msg_new.metadata, ServerMessageMetadata) + assert session_msg_new.metadata.close_sse_stream is not None + assert session_msg_new.metadata.close_standalone_sse_stream is not None + + +@pytest.mark.anyio +async def test_streamable_http_client_receives_priming_event( + event_server: tuple[SimpleEventStore, str], +) -> None: + """Client should receive priming event (resumption token update) on POST SSE stream.""" + _, server_url = event_server + + captured_resumption_tokens: list[str] = [] + + async def on_resumption_token_update(token: str) -> None: + captured_resumption_tokens.append(token) + + async with streamable_http_client(f"{server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + + # Call tool with resumption token callback via send_request + metadata = ClientMessageMetadata( + on_resumption_token_update=on_resumption_token_update, + ) + result = await session.send_request( + types.ClientRequest( + types.CallToolRequest( + params=types.CallToolRequestParams(name="test_tool", arguments={}), + ) + ), + types.CallToolResult, + metadata=metadata, + ) + assert result is not None + + # Should have received priming event token BEFORE response data + # Priming event = 1 token (empty data, id only) + # Response = 1 token (actual JSON-RPC response) + # Total = 2 tokens minimum + assert len(captured_resumption_tokens) >= 2, ( + f"Server must send priming event before response. " + f"Expected >= 2 tokens (priming + response), got {len(captured_resumption_tokens)}" + ) + assert captured_resumption_tokens[0] is not None + + +@pytest.mark.anyio +async def test_server_close_sse_stream_via_context( + event_server: tuple[SimpleEventStore, str], +) -> None: + """Server tool can call ctx.close_sse_stream() to close connection.""" + _, server_url = event_server + + async with streamable_http_client(f"{server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + + # Call tool that closes stream mid-operation + # This should NOT raise NotImplementedError when fully implemented + result = await session.call_tool("tool_with_stream_close", {}) + + # Client should still receive complete response (via auto-reconnect) + assert result is not None + assert len(result.content) > 0 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Done" + + +@pytest.mark.anyio +async def test_streamable_http_client_auto_reconnects( + event_server: tuple[SimpleEventStore, str], +) -> None: + """Client should auto-reconnect with Last-Event-ID when server closes after priming event.""" + _, server_url = event_server + captured_notifications: list[str] = [] + + async def message_handler( + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): # pragma: no branch + return # pragma: no cover + if isinstance(message, types.ServerNotification): # pragma: no branch + if isinstance(message.root, types.LoggingMessageNotification): # pragma: no branch + captured_notifications.append(str(message.root.params.data)) + + async with streamable_http_client(f"{server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession( + read_stream, + write_stream, + message_handler=message_handler, + ) as session: + await session.initialize() + + # Call tool that: + # 1. Sends notification + # 2. Closes SSE stream + # 3. Sends more notifications (stored in event_store) + # 4. Returns response + result = await session.call_tool("tool_with_stream_close", {}) + + # Client should have auto-reconnected and received ALL notifications + assert len(captured_notifications) >= 2, ( + "Client should auto-reconnect and receive notifications sent both before and after stream close" + ) + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Done" + + +@pytest.mark.anyio +async def test_streamable_http_client_respects_retry_interval( + event_server: tuple[SimpleEventStore, str], +) -> None: + """Client MUST respect retry field, waiting specified ms before reconnecting.""" + _, server_url = event_server + + async with streamable_http_client(f"{server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + + start_time = time.monotonic() + result = await session.call_tool("tool_with_stream_close", {}) + elapsed = time.monotonic() - start_time + + # Verify result was received + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Done" + + # The elapsed time should include at least the retry interval + # if reconnection occurred. This test may be flaky depending on + # implementation details, but demonstrates the expected behavior. + # Note: This assertion may need adjustment based on actual implementation + assert elapsed >= 0.4, f"Client should wait ~500ms before reconnecting, but elapsed time was {elapsed:.3f}s" + + +@pytest.mark.anyio +async def test_streamable_http_sse_polling_full_cycle( + event_server: tuple[SimpleEventStore, str], +) -> None: + """End-to-end test: server closes stream, client reconnects, receives all events.""" + _, server_url = event_server + all_notifications: list[str] = [] + + async def message_handler( + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): # pragma: no branch + return # pragma: no cover + if isinstance(message, types.ServerNotification): # pragma: no branch + if isinstance(message.root, types.LoggingMessageNotification): # pragma: no branch + all_notifications.append(str(message.root.params.data)) + + async with streamable_http_client(f"{server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession( + read_stream, + write_stream, + message_handler=message_handler, + ) as session: + await session.initialize() + + # Call tool that simulates polling pattern: + # 1. Server sends priming event + # 2. Server sends "Before close" notification + # 3. Server closes stream (calls close_sse_stream) + # 4. (client reconnects automatically) + # 5. Server sends "After close" notification + # 6. Server sends final response + result = await session.call_tool("tool_with_stream_close", {}) + + # Verify all notifications received in order + assert "Before close" in all_notifications, "Should receive notification sent before stream close" + assert "After close" in all_notifications, ( + "Should receive notification sent after stream close (via auto-reconnect)" + ) + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Done" + + +@pytest.mark.anyio +async def test_streamable_http_events_replayed_after_disconnect( + event_server: tuple[SimpleEventStore, str], +) -> None: + """Events sent while client is disconnected should be replayed on reconnect.""" + _, server_url = event_server + notification_data: list[str] = [] + + async def message_handler( + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): # pragma: no branch + return # pragma: no cover + if isinstance(message, types.ServerNotification): # pragma: no branch + if isinstance(message.root, types.LoggingMessageNotification): # pragma: no branch + notification_data.append(str(message.root.params.data)) + + async with streamable_http_client(f"{server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession( + read_stream, + write_stream, + message_handler=message_handler, + ) as session: + await session.initialize() + + # Tool sends: notification1, close_stream, notification2, notification3, response + # Client should receive all notifications even though 2&3 were sent during disconnect + result = await session.call_tool("tool_with_multiple_notifications_and_close", {}) + + assert "notification1" in notification_data, "Should receive notification1 (sent before close)" + assert "notification2" in notification_data, "Should receive notification2 (sent after close, replayed)" + assert "notification3" in notification_data, "Should receive notification3 (sent after close, replayed)" + + # Verify order: notification1 should come before notification2 and notification3 + idx1 = notification_data.index("notification1") + idx2 = notification_data.index("notification2") + idx3 = notification_data.index("notification3") + assert idx1 < idx2 < idx3, "Notifications should be received in order" + + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "All notifications sent" + + +@pytest.mark.anyio +async def test_streamable_http_multiple_reconnections( + event_server: tuple[SimpleEventStore, str], +): + """Verify multiple close_sse_stream() calls each trigger a client reconnect. + + Server uses retry_interval=500ms, tool sleeps 600ms after each close to ensure + client has time to reconnect before the next checkpoint. + + With 3 checkpoints, we expect 8 resumption tokens: + - 1 priming (initial POST connection) + - 3 notifications (checkpoint_0, checkpoint_1, checkpoint_2) + - 3 priming (one per reconnect after each close) + - 1 response + """ + _, server_url = event_server + resumption_tokens: list[str] = [] + + async def on_resumption_token(token: str) -> None: + resumption_tokens.append(token) + + async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + + # Use send_request with metadata to track resumption tokens + metadata = ClientMessageMetadata(on_resumption_token_update=on_resumption_token) + result = await session.send_request( + types.ClientRequest( + types.CallToolRequest( + method="tools/call", + params=types.CallToolRequestParams( + name="tool_with_multiple_stream_closes", + # retry_interval=500ms, so sleep 600ms to ensure reconnect completes + arguments={"checkpoints": 3, "sleep_time": 0.6}, + ), + ) + ), + types.CallToolResult, + metadata=metadata, + ) + + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert "Completed 3 checkpoints" in result.content[0].text + + # 4 priming + 3 notifications + 1 response = 8 tokens + assert len(resumption_tokens) == 8, ( # pragma: no cover + f"Expected 8 resumption tokens (4 priming + 3 notifs + 1 response), " + f"got {len(resumption_tokens)}: {resumption_tokens}" + ) + + +@pytest.mark.anyio +async def test_standalone_get_stream_reconnection( + event_server: tuple[SimpleEventStore, str], +) -> None: + """ + Test that standalone GET stream automatically reconnects after server closes it. + + Verifies: + 1. Client receives notification 1 via GET stream + 2. Server closes GET stream + 3. Client reconnects with Last-Event-ID + 4. Client receives notification 2 on new connection + + Note: Requires event_server fixture (with event store) because close_standalone_sse_stream + callback is only provided when event_store is configured and protocol version >= 2025-11-25. + """ + _, server_url = event_server + received_notifications: list[str] = [] + + async def message_handler( + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): + return # pragma: no cover + if isinstance(message, types.ServerNotification): # pragma: no branch + if isinstance(message.root, types.ResourceUpdatedNotification): # pragma: no branch + received_notifications.append(str(message.root.params.uri)) + + async with streamable_http_client(f"{server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession( + read_stream, + write_stream, + message_handler=message_handler, + ) as session: + await session.initialize() + + # Call tool that: + # 1. Sends notification_1 via GET stream + # 2. Closes standalone GET stream + # 3. Sends notification_2 (stored in event_store) + # 4. Returns response + result = await session.call_tool("tool_with_standalone_stream_close", {}) + + # Verify the tool completed + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Standalone stream close test done" + + # Verify both notifications were received + assert "http://notification_1/" in received_notifications, ( + f"Should receive notification 1 (sent before GET stream close), got: {received_notifications}" + ) + assert "http://notification_2/" in received_notifications, ( + f"Should receive notification 2 after reconnect, got: {received_notifications}" + ) + + +@pytest.mark.anyio +async def test_streamable_http_client_does_not_mutate_provided_client( + basic_server: None, basic_server_url: str +) -> None: + """Test that streamable_http_client does not mutate the provided httpx client's headers.""" + # Create a client with custom headers + original_headers = { + "X-Custom-Header": "custom-value", + "Authorization": "Bearer test-token", + } + + async with httpx.AsyncClient(headers=original_headers, follow_redirects=True) as custom_client: + # Use the client with streamable_http_client + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=custom_client) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + result = await session.initialize() + assert isinstance(result, InitializeResult) + + # Verify client headers were not mutated with MCP protocol headers + # If accept header exists, it should still be httpx default, not MCP's + if "accept" in custom_client.headers: # pragma: no branch + assert custom_client.headers.get("accept") == "*/*" + # MCP content-type should not have been added + assert custom_client.headers.get("content-type") != "application/json" + + # Verify custom headers are still present and unchanged + assert custom_client.headers.get("X-Custom-Header") == "custom-value" + assert custom_client.headers.get("Authorization") == "Bearer test-token" + + +@pytest.mark.anyio +async def test_streamable_http_client_mcp_headers_override_defaults( + context_aware_server: None, basic_server_url: str +) -> None: + """Test that MCP protocol headers override httpx.AsyncClient default headers.""" + # httpx.AsyncClient has default "accept: */*" header + # We need to verify that our MCP accept header overrides it in actual requests + + async with httpx.AsyncClient(follow_redirects=True) as client: + # Verify client has default accept header + assert client.headers.get("accept") == "*/*" + + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=client) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + await session.initialize() + + # Use echo_headers tool to see what headers the server actually received + tool_result = await session.call_tool("echo_headers", {}) + assert len(tool_result.content) == 1 + assert isinstance(tool_result.content[0], TextContent) + headers_data = json.loads(tool_result.content[0].text) + + # Verify MCP protocol headers were sent (not httpx defaults) + assert "accept" in headers_data + assert "application/json" in headers_data["accept"] + assert "text/event-stream" in headers_data["accept"] + + assert "content-type" in headers_data + assert headers_data["content-type"] == "application/json" + + +@pytest.mark.anyio +async def test_streamable_http_client_preserves_custom_with_mcp_headers( + context_aware_server: None, basic_server_url: str +) -> None: + """Test that both custom headers and MCP protocol headers are sent in requests.""" + custom_headers = { + "X-Custom-Header": "custom-value", + "X-Request-Id": "req-123", + "Authorization": "Bearer test-token", + } + + async with httpx.AsyncClient(headers=custom_headers, follow_redirects=True) as client: + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=client) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + await session.initialize() + + # Use echo_headers tool to verify both custom and MCP headers are present + tool_result = await session.call_tool("echo_headers", {}) + assert len(tool_result.content) == 1 + assert isinstance(tool_result.content[0], TextContent) + headers_data = json.loads(tool_result.content[0].text) + + # Verify custom headers are present + assert headers_data.get("x-custom-header") == "custom-value" + assert headers_data.get("x-request-id") == "req-123" + assert headers_data.get("authorization") == "Bearer test-token" + + # Verify MCP protocol headers are also present + assert "accept" in headers_data + assert "application/json" in headers_data["accept"] + assert "text/event-stream" in headers_data["accept"] + + assert "content-type" in headers_data + assert headers_data["content-type"] == "application/json" + + +@pytest.mark.anyio +async def test_streamable_http_transport_deprecated_params_ignored(basic_server: None, basic_server_url: str) -> None: + """Test that deprecated parameters passed to StreamableHTTPTransport are properly ignored.""" + with pytest.warns(DeprecationWarning): + transport = StreamableHTTPTransport( # pyright: ignore[reportDeprecated] + url=f"{basic_server_url}/mcp", + headers={"X-Should-Be-Ignored": "ignored"}, + timeout=999.0, + sse_read_timeout=999.0, + auth=None, + ) + + headers = transport._prepare_headers() + assert "X-Should-Be-Ignored" not in headers + assert headers["accept"] == "application/json, text/event-stream" + assert headers["content-type"] == "application/json" + + +@pytest.mark.anyio +async def test_streamablehttp_client_deprecation_warning(basic_server: None, basic_server_url: str) -> None: + """Test that the old streamablehttp_client() function issues a deprecation warning.""" + with pytest.warns(DeprecationWarning, match="Use `streamable_http_client` instead"): + async with streamablehttp_client(f"{basic_server_url}/mcp") as ( # pyright: ignore[reportDeprecated] + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + await session.initialize() + tools = await session.list_tools() + assert len(tools.tools) > 0 diff --git a/tests/shared/test_tool_name_validation.py b/tests/shared/test_tool_name_validation.py new file mode 100644 index 0000000000..4746f3f9f8 --- /dev/null +++ b/tests/shared/test_tool_name_validation.py @@ -0,0 +1,199 @@ +"""Tests for tool name validation utilities (SEP-986).""" + +import logging + +import pytest + +from mcp.shared.tool_name_validation import ( + issue_tool_name_warning, + validate_and_warn_tool_name, + validate_tool_name, +) + + +class TestValidateToolName: + """Tests for validate_tool_name function.""" + + class TestValidNames: + """Test cases for valid tool names.""" + + @pytest.mark.parametrize( + "tool_name", + [ + "getUser", + "get_user_profile", + "user-profile-update", + "admin.tools.list", + "DATA_EXPORT_v2.1", + "a", + "a" * 128, + ], + ids=[ + "simple_alphanumeric", + "with_underscores", + "with_dashes", + "with_dots", + "mixed_characters", + "single_character", + "max_length_128", + ], + ) + def test_accepts_valid_names(self, tool_name: str) -> None: + """Valid tool names should pass validation with no warnings.""" + result = validate_tool_name(tool_name) + assert result.is_valid is True + assert result.warnings == [] + + class TestInvalidNames: + """Test cases for invalid tool names.""" + + def test_rejects_empty_name(self) -> None: + """Empty names should be rejected.""" + result = validate_tool_name("") + assert result.is_valid is False + assert "Tool name cannot be empty" in result.warnings + + def test_rejects_name_exceeding_max_length(self) -> None: + """Names exceeding 128 characters should be rejected.""" + result = validate_tool_name("a" * 129) + assert result.is_valid is False + assert any("exceeds maximum length of 128 characters (current: 129)" in w for w in result.warnings) + + @pytest.mark.parametrize( + "tool_name,expected_char", + [ + ("get user profile", "' '"), + ("get,user,profile", "','"), + ("user/profile/update", "'/'"), + ("user@domain.com", "'@'"), + ], + ids=[ + "with_spaces", + "with_commas", + "with_slashes", + "with_at_symbol", + ], + ) + def test_rejects_invalid_characters(self, tool_name: str, expected_char: str) -> None: + """Names with invalid characters should be rejected.""" + result = validate_tool_name(tool_name) + assert result.is_valid is False + assert any("invalid characters" in w and expected_char in w for w in result.warnings) + + def test_rejects_multiple_invalid_chars(self) -> None: + """Names with multiple invalid chars should list all of them.""" + result = validate_tool_name("user name@domain,com") + assert result.is_valid is False + warning = next(w for w in result.warnings if "invalid characters" in w) + assert "' '" in warning + assert "'@'" in warning + assert "','" in warning + + def test_rejects_unicode_characters(self) -> None: + """Names with unicode characters should be rejected.""" + result = validate_tool_name("user-\u00f1ame") # n with tilde + assert result.is_valid is False + + class TestWarningsForProblematicPatterns: + """Test cases for valid names that generate warnings.""" + + def test_warns_on_leading_dash(self) -> None: + """Names starting with dash should generate warning but be valid.""" + result = validate_tool_name("-get-user") + assert result.is_valid is True + assert any("starts or ends with a dash" in w for w in result.warnings) + + def test_warns_on_trailing_dash(self) -> None: + """Names ending with dash should generate warning but be valid.""" + result = validate_tool_name("get-user-") + assert result.is_valid is True + assert any("starts or ends with a dash" in w for w in result.warnings) + + def test_warns_on_leading_dot(self) -> None: + """Names starting with dot should generate warning but be valid.""" + result = validate_tool_name(".get.user") + assert result.is_valid is True + assert any("starts or ends with a dot" in w for w in result.warnings) + + def test_warns_on_trailing_dot(self) -> None: + """Names ending with dot should generate warning but be valid.""" + result = validate_tool_name("get.user.") + assert result.is_valid is True + assert any("starts or ends with a dot" in w for w in result.warnings) + + +class TestIssueToolNameWarning: + """Tests for issue_tool_name_warning function.""" + + def test_logs_warnings(self, caplog: pytest.LogCaptureFixture) -> None: + """Warnings should be logged at WARNING level.""" + warnings = ["Warning 1", "Warning 2"] + with caplog.at_level(logging.WARNING): + issue_tool_name_warning("test-tool", warnings) + + assert 'Tool name validation warning for "test-tool"' in caplog.text + assert "- Warning 1" in caplog.text + assert "- Warning 2" in caplog.text + assert "Tool registration will proceed" in caplog.text + assert "SEP-986" in caplog.text + + def test_no_logging_for_empty_warnings(self, caplog: pytest.LogCaptureFixture) -> None: + """Empty warnings list should not produce any log output.""" + with caplog.at_level(logging.WARNING): + issue_tool_name_warning("test-tool", []) + + assert caplog.text == "" + + +class TestValidateAndWarnToolName: + """Tests for validate_and_warn_tool_name function.""" + + def test_returns_true_for_valid_name(self) -> None: + """Valid names should return True.""" + assert validate_and_warn_tool_name("valid-tool-name") is True + + def test_returns_false_for_invalid_name(self) -> None: + """Invalid names should return False.""" + assert validate_and_warn_tool_name("") is False + assert validate_and_warn_tool_name("a" * 129) is False + assert validate_and_warn_tool_name("invalid name") is False + + def test_logs_warnings_for_invalid_name(self, caplog: pytest.LogCaptureFixture) -> None: + """Invalid names should trigger warning logs.""" + with caplog.at_level(logging.WARNING): + validate_and_warn_tool_name("invalid name") + + assert "Tool name validation warning" in caplog.text + + def test_no_warnings_for_clean_valid_name(self, caplog: pytest.LogCaptureFixture) -> None: + """Clean valid names should not produce any log output.""" + with caplog.at_level(logging.WARNING): + result = validate_and_warn_tool_name("clean-tool-name") + + assert result is True + assert caplog.text == "" + + +class TestEdgeCases: + """Test edge cases and robustness.""" + + @pytest.mark.parametrize( + "tool_name,is_valid,expected_warning_fragment", + [ + ("...", True, "starts or ends with a dot"), + ("---", True, "starts or ends with a dash"), + ("///", False, "invalid characters"), + ("user@name123", False, "invalid characters"), + ], + ids=[ + "only_dots", + "only_dashes", + "only_slashes", + "mixed_valid_invalid", + ], + ) + def test_edge_cases(self, tool_name: str, is_valid: bool, expected_warning_fragment: str) -> None: + """Various edge cases should be handled correctly.""" + result = validate_tool_name(tool_name) + assert result.is_valid is is_valid + assert any(expected_warning_fragment in w for w in result.warnings) diff --git a/tests/shared/test_win32_utils.py b/tests/shared/test_win32_utils.py new file mode 100644 index 0000000000..e0f9cb4995 --- /dev/null +++ b/tests/shared/test_win32_utils.py @@ -0,0 +1,10 @@ +"""Windows-specific test utilities.""" + + +def escape_path_for_python(path: str) -> str: + """Escape a file path for use in Python code strings. + + Converts backslashes to forward slashes which work on all platforms + and don't need escaping in Python strings. + """ + return repr(path.replace("\\", "/")) diff --git a/tests/shared/test_ws.py b/tests/shared/test_ws.py index 1381c8153c..f093cb4927 100644 --- a/tests/shared/test_ws.py +++ b/tests/shared/test_ws.py @@ -2,6 +2,7 @@ import socket import time from collections.abc import AsyncGenerator, Generator +from typing import Any import anyio import pytest @@ -9,6 +10,7 @@ from pydantic import AnyUrl from starlette.applications import Starlette from starlette.routing import WebSocketRoute +from starlette.websockets import WebSocket from mcp.client.session import ClientSession from mcp.client.websocket import websocket_client @@ -24,6 +26,7 @@ TextResourceContents, Tool, ) +from tests.test_helpers import wait_for_server SERVER_NAME = "test_server_for_WS" @@ -41,7 +44,7 @@ def server_url(server_port: int) -> str: # Test server implementation -class ServerTest(Server): +class ServerTest(Server): # pragma: no cover def __init__(self): super().__init__(SERVER_NAME) @@ -54,11 +57,7 @@ async def handle_read_resource(uri: AnyUrl) -> str | bytes: await anyio.sleep(2.0) return f"Slow response from {uri.host}" - raise McpError( - error=ErrorData( - code=404, message="OOPS! no resource with that URI was found" - ) - ) + raise McpError(error=ErrorData(code=404, message="OOPS! no resource with that URI was found")) @self.list_tools() async def handle_list_tools() -> list[Tool]: @@ -71,22 +70,18 @@ async def handle_list_tools() -> list[Tool]: ] @self.call_tool() - async def handle_call_tool(name: str, args: dict) -> list[TextContent]: + async def handle_call_tool(name: str, args: dict[str, Any]) -> list[TextContent]: return [TextContent(type="text", text=f"Called {name}")] # Test fixtures -def make_server_app() -> Starlette: +def make_server_app() -> Starlette: # pragma: no cover """Create test Starlette app with WebSocket transport""" server = ServerTest() - async def handle_ws(websocket): - async with websocket_server( - websocket.scope, websocket.receive, websocket.send - ) as streams: - await server.run( - streams[0], streams[1], server.create_initialization_options() - ) + async def handle_ws(websocket: WebSocket): + async with websocket_server(websocket.scope, websocket.receive, websocket.send) as streams: + await server.run(streams[0], streams[1], server.create_initialization_options()) app = Starlette( routes=[ @@ -97,13 +92,9 @@ async def handle_ws(websocket): return app -def run_server(server_port: int) -> None: +def run_server(server_port: int) -> None: # pragma: no cover app = make_server_app() - server = uvicorn.Server( - config=uvicorn.Config( - app=app, host="127.0.0.1", port=server_port, log_level="error" - ) - ) + server = uvicorn.Server(config=uvicorn.Config(app=app, host="127.0.0.1", port=server_port, log_level="error")) print(f"starting server on {server_port}") server.run() @@ -113,28 +104,15 @@ def run_server(server_port: int) -> None: time.sleep(0.5) -@pytest.fixture() +@pytest.fixture() # pragma: no cover def server(server_port: int) -> Generator[None, None, None]: - proc = multiprocessing.Process( - target=run_server, kwargs={"server_port": server_port}, daemon=True - ) + proc = multiprocessing.Process(target=run_server, kwargs={"server_port": server_port}, daemon=True) print("starting process") proc.start() # Wait for server to be running - max_attempts = 20 - attempt = 0 print("waiting for server to start") - while attempt < max_attempts: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect(("127.0.0.1", server_port)) - break - except ConnectionRefusedError: - time.sleep(0.1) - attempt += 1 - else: - raise RuntimeError(f"Server failed to start after {max_attempts} attempts") + wait_for_server(server_port) yield @@ -142,14 +120,12 @@ def server(server_port: int) -> Generator[None, None, None]: # Signal the server to stop proc.kill() proc.join(timeout=2) - if proc.is_alive(): + if proc.is_alive(): # pragma: no cover print("server process failed to terminate") @pytest.fixture() -async def initialized_ws_client_session( - server, server_url: str -) -> AsyncGenerator[ClientSession, None]: +async def initialized_ws_client_session(server: None, server_url: str) -> AsyncGenerator[ClientSession, None]: """Create and initialize a WebSocket client session""" async with websocket_client(server_url + "/ws") as streams: async with ClientSession(*streams) as session: @@ -186,9 +162,7 @@ async def test_ws_client_happy_request_and_response( initialized_ws_client_session: ClientSession, ) -> None: """Test a successful request and response via WebSocket""" - result = await initialized_ws_client_session.read_resource( - AnyUrl("foobar://example") - ) + result = await initialized_ws_client_session.read_resource(AnyUrl("foobar://example")) assert isinstance(result, ReadResourceResult) assert isinstance(result.contents, list) assert len(result.contents) > 0 @@ -218,9 +192,7 @@ async def test_ws_client_timeout( # Now test that we can still use the session after a timeout with anyio.fail_after(5): # Longer timeout to allow completion - result = await initialized_ws_client_session.read_resource( - AnyUrl("foobar://example") - ) + result = await initialized_ws_client_session.read_resource(AnyUrl("foobar://example")) assert isinstance(result, ReadResourceResult) assert isinstance(result.contents, list) assert len(result.contents) > 0 diff --git a/tests/test_examples.py b/tests/test_examples.py index c5e8ec9d74..6f5464e394 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,11 +1,16 @@ """Tests for example servers""" +# TODO(Marcelo): The `examples` directory needs to be importable as a package. +# pyright: reportMissingImports=false +# pyright: reportUnknownVariableType=false +# pyright: reportUnknownArgumentType=false +# pyright: reportUnknownMemberType=false + +import sys import pytest from pytest_examples import CodeExample, EvalExample, find_examples -from mcp.shared.memory import ( - create_connected_server_and_client_session as client_session, -) +from mcp.shared.memory import create_connected_server_and_client_session as client_session from mcp.types import TextContent, TextResourceContents @@ -29,9 +34,7 @@ async def test_complex_inputs(): async with client_session(mcp._mcp_server) as client: tank = {"shrimp": [{"name": "bob"}, {"name": "alice"}]} - result = await client.call_tool( - "name_shrimp", {"tank": tank, "extra_names": ["charlie"]} - ) + result = await client.call_tool("name_shrimp", {"tank": tank, "extra_names": ["charlie"]}) assert len(result.content) == 3 assert isinstance(result.content[0], TextContent) assert isinstance(result.content[1], TextContent) @@ -42,7 +45,24 @@ async def test_complex_inputs(): @pytest.mark.anyio -async def test_desktop(monkeypatch): +async def test_direct_call_tool_result_return(): + """Test the CallToolResult echo server""" + from examples.fastmcp.direct_call_tool_result_return import mcp + + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("echo", {"text": "hello"}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == "hello" + assert result.structuredContent + assert result.structuredContent["text"] == "hello" + assert isinstance(result.meta, dict) + assert result.meta["some"] == "metadata" + + +@pytest.mark.anyio +async def test_desktop(monkeypatch: pytest.MonkeyPatch): """Test the desktop server""" from pathlib import Path @@ -52,12 +72,12 @@ async def test_desktop(monkeypatch): # Mock desktop directory listing mock_files = [Path("/fake/path/file1.txt"), Path("/fake/path/file2.txt")] - monkeypatch.setattr(Path, "iterdir", lambda self: mock_files) + monkeypatch.setattr(Path, "iterdir", lambda self: mock_files) # type: ignore[reportUnknownArgumentType] monkeypatch.setattr(Path, "home", lambda: Path("/fake/home")) async with client_session(mcp._mcp_server) as client: - # Test the add function - result = await client.call_tool("add", {"a": 1, "b": 2}) + # Test the sum function + result = await client.call_tool("sum", {"a": 1, "b": 2}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) @@ -69,19 +89,26 @@ async def test_desktop(monkeypatch): content = result.contents[0] assert isinstance(content, TextResourceContents) assert isinstance(content.text, str) - assert "/fake/path/file1.txt" in content.text - assert "/fake/path/file2.txt" in content.text + if sys.platform == "win32": # pragma: no cover + file_1 = "/fake/path/file1.txt".replace("/", "\\\\") # might be a bug + file_2 = "/fake/path/file2.txt".replace("/", "\\\\") # might be a bug + assert file_1 in content.text + assert file_2 in content.text + # might be a bug, but the test is passing + else: # pragma: no cover + assert "/fake/path/file1.txt" in content.text + assert "/fake/path/file2.txt" in content.text @pytest.mark.parametrize("example", find_examples("README.md"), ids=str) def test_docs_examples(example: CodeExample, eval_example: EvalExample): - ruff_ignore: list[str] = ["F841", "I001"] + ruff_ignore: list[str] = ["F841", "I001", "F821"] # F821: undefined names (snippets lack imports) - eval_example.set_config( - ruff_ignore=ruff_ignore, target_version="py310", line_length=88 - ) + # Use project's actual line length of 120 + eval_example.set_config(ruff_ignore=ruff_ignore, target_version="py310", line_length=120) + # Use Ruff for both formatting and linting (skip Black) if eval_example.update_examples: # pragma: no cover - eval_example.format(example) + eval_example.format_ruff(example) else: - eval_example.lint(example) + eval_example.lint_ruff(example) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000000..5c04c269ff --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,31 @@ +"""Common test utilities for MCP server tests.""" + +import socket +import time + + +def wait_for_server(port: int, timeout: float = 20.0) -> None: + """Wait for server to be ready to accept connections. + + Polls the server port until it accepts connections or timeout is reached. + This eliminates race conditions without arbitrary sleeps. + + Args: + port: The port number to check + timeout: Maximum time to wait in seconds (default 5.0) + + Raises: + TimeoutError: If server doesn't start within the timeout period + """ + start_time = time.time() + while time.time() - start_time < timeout: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.1) + s.connect(("127.0.0.1", port)) + # Server is ready + return + except (ConnectionRefusedError, OSError): + # Server not ready yet, retry quickly + time.sleep(0.01) + raise TimeoutError(f"Server on port {port} did not start within {timeout} seconds") # pragma: no cover diff --git a/tests/test_types.py b/tests/test_types.py index a39d33412d..1c16c3cc6e 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,10 +1,27 @@ +from typing import Any + import pytest from mcp.types import ( LATEST_PROTOCOL_VERSION, + ClientCapabilities, ClientRequest, + CreateMessageRequestParams, + CreateMessageResult, + CreateMessageResultWithTools, + Implementation, + InitializeRequest, + InitializeRequestParams, JSONRPCMessage, JSONRPCRequest, + ListToolsResult, + SamplingCapability, + SamplingMessage, + TextContent, + Tool, + ToolChoice, + ToolResultContent, + ToolUseContent, ) @@ -30,3 +47,317 @@ async def test_jsonrpc_request(): assert request.root.method == "initialize" assert request.root.params is not None assert request.root.params["protocolVersion"] == LATEST_PROTOCOL_VERSION + + +@pytest.mark.anyio +async def test_method_initialization(): + """ + Test that the method is automatically set on object creation. + Testing just for InitializeRequest to keep the test simple, but should be set for other types as well. + """ + initialize_request = InitializeRequest( + params=InitializeRequestParams( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ClientCapabilities(), + clientInfo=Implementation( + name="mcp", + version="0.1.0", + ), + ) + ) + + assert initialize_request.method == "initialize", "method should be set to 'initialize'" + assert initialize_request.params is not None + assert initialize_request.params.protocolVersion == LATEST_PROTOCOL_VERSION + + +@pytest.mark.anyio +async def test_tool_use_content(): + """Test ToolUseContent type for SEP-1577.""" + tool_use_data = { + "type": "tool_use", + "name": "get_weather", + "id": "call_abc123", + "input": {"location": "San Francisco", "unit": "celsius"}, + } + + tool_use = ToolUseContent.model_validate(tool_use_data) + assert tool_use.type == "tool_use" + assert tool_use.name == "get_weather" + assert tool_use.id == "call_abc123" + assert tool_use.input == {"location": "San Francisco", "unit": "celsius"} + + # Test serialization + serialized = tool_use.model_dump(by_alias=True, exclude_none=True) + assert serialized["type"] == "tool_use" + assert serialized["name"] == "get_weather" + + +@pytest.mark.anyio +async def test_tool_result_content(): + """Test ToolResultContent type for SEP-1577.""" + tool_result_data = { + "type": "tool_result", + "toolUseId": "call_abc123", + "content": [{"type": "text", "text": "It's 72°F in San Francisco"}], + "isError": False, + } + + tool_result = ToolResultContent.model_validate(tool_result_data) + assert tool_result.type == "tool_result" + assert tool_result.toolUseId == "call_abc123" + assert len(tool_result.content) == 1 + assert tool_result.isError is False + + # Test with empty content (should default to []) + minimal_result_data = {"type": "tool_result", "toolUseId": "call_xyz"} + minimal_result = ToolResultContent.model_validate(minimal_result_data) + assert minimal_result.content == [] + + +@pytest.mark.anyio +async def test_tool_choice(): + """Test ToolChoice type for SEP-1577.""" + # Test with mode + tool_choice_data = {"mode": "required"} + tool_choice = ToolChoice.model_validate(tool_choice_data) + assert tool_choice.mode == "required" + + # Test with minimal data (all fields optional) + minimal_choice = ToolChoice.model_validate({}) + assert minimal_choice.mode is None + + # Test different modes + auto_choice = ToolChoice.model_validate({"mode": "auto"}) + assert auto_choice.mode == "auto" + + none_choice = ToolChoice.model_validate({"mode": "none"}) + assert none_choice.mode == "none" + + +@pytest.mark.anyio +async def test_sampling_message_with_user_role(): + """Test SamplingMessage with user role for SEP-1577.""" + # Test with single content + user_msg_data = {"role": "user", "content": {"type": "text", "text": "Hello"}} + user_msg = SamplingMessage.model_validate(user_msg_data) + assert user_msg.role == "user" + assert isinstance(user_msg.content, TextContent) + + # Test with array of content including tool result + multi_content_data: dict[str, Any] = { + "role": "user", + "content": [ + {"type": "text", "text": "Here's the result:"}, + {"type": "tool_result", "toolUseId": "call_123", "content": []}, + ], + } + multi_msg = SamplingMessage.model_validate(multi_content_data) + assert multi_msg.role == "user" + assert isinstance(multi_msg.content, list) + assert len(multi_msg.content) == 2 + + +@pytest.mark.anyio +async def test_sampling_message_with_assistant_role(): + """Test SamplingMessage with assistant role for SEP-1577.""" + # Test with tool use content + assistant_msg_data = { + "role": "assistant", + "content": { + "type": "tool_use", + "name": "search", + "id": "call_456", + "input": {"query": "MCP protocol"}, + }, + } + assistant_msg = SamplingMessage.model_validate(assistant_msg_data) + assert assistant_msg.role == "assistant" + assert isinstance(assistant_msg.content, ToolUseContent) + + # Test with array of mixed content + multi_content_data: dict[str, Any] = { + "role": "assistant", + "content": [ + {"type": "text", "text": "Let me search for that..."}, + {"type": "tool_use", "name": "search", "id": "call_789", "input": {}}, + ], + } + multi_msg = SamplingMessage.model_validate(multi_content_data) + assert isinstance(multi_msg.content, list) + assert len(multi_msg.content) == 2 + + +@pytest.mark.anyio +async def test_sampling_message_backward_compatibility(): + """Test that SamplingMessage maintains backward compatibility.""" + # Old-style message (single content, no tools) + old_style_data = {"role": "user", "content": {"type": "text", "text": "Hello"}} + old_msg = SamplingMessage.model_validate(old_style_data) + assert old_msg.role == "user" + assert isinstance(old_msg.content, TextContent) + + # New-style message with tool content + new_style_data: dict[str, Any] = { + "role": "assistant", + "content": {"type": "tool_use", "name": "test", "id": "call_1", "input": {}}, + } + new_msg = SamplingMessage.model_validate(new_style_data) + assert new_msg.role == "assistant" + assert isinstance(new_msg.content, ToolUseContent) + + # Array content + array_style_data: dict[str, Any] = { + "role": "user", + "content": [{"type": "text", "text": "Result:"}, {"type": "tool_result", "toolUseId": "call_1", "content": []}], + } + array_msg = SamplingMessage.model_validate(array_style_data) + assert isinstance(array_msg.content, list) + + +@pytest.mark.anyio +async def test_create_message_request_params_with_tools(): + """Test CreateMessageRequestParams with tools for SEP-1577.""" + tool = Tool( + name="get_weather", + description="Get weather information", + inputSchema={"type": "object", "properties": {"location": {"type": "string"}}}, + ) + + params = CreateMessageRequestParams( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text="What's the weather?"))], + maxTokens=1000, + tools=[tool], + toolChoice=ToolChoice(mode="auto"), + ) + + assert params.tools is not None + assert len(params.tools) == 1 + assert params.tools[0].name == "get_weather" + assert params.toolChoice is not None + assert params.toolChoice.mode == "auto" + + +@pytest.mark.anyio +async def test_create_message_result_with_tool_use(): + """Test CreateMessageResultWithTools with tool use content for SEP-1577.""" + result_data = { + "role": "assistant", + "content": {"type": "tool_use", "name": "search", "id": "call_123", "input": {"query": "test"}}, + "model": "claude-3", + "stopReason": "toolUse", + } + + # Tool use content uses CreateMessageResultWithTools + result = CreateMessageResultWithTools.model_validate(result_data) + assert result.role == "assistant" + assert isinstance(result.content, ToolUseContent) + assert result.stopReason == "toolUse" + assert result.model == "claude-3" + + # Test content_as_list with single content (covers else branch) + content_list = result.content_as_list + assert len(content_list) == 1 + assert content_list[0] == result.content + + +@pytest.mark.anyio +async def test_create_message_result_basic(): + """Test CreateMessageResult with basic text content (backwards compatible).""" + result_data = { + "role": "assistant", + "content": {"type": "text", "text": "Hello!"}, + "model": "claude-3", + "stopReason": "endTurn", + } + + # Basic content uses CreateMessageResult (single content, no arrays) + result = CreateMessageResult.model_validate(result_data) + assert result.role == "assistant" + assert isinstance(result.content, TextContent) + assert result.content.text == "Hello!" + assert result.stopReason == "endTurn" + assert result.model == "claude-3" + + +@pytest.mark.anyio +async def test_client_capabilities_with_sampling_tools(): + """Test ClientCapabilities with nested sampling capabilities for SEP-1577.""" + # New structured format + capabilities_data: dict[str, Any] = { + "sampling": {"tools": {}}, + } + capabilities = ClientCapabilities.model_validate(capabilities_data) + assert capabilities.sampling is not None + assert isinstance(capabilities.sampling, SamplingCapability) + assert capabilities.sampling.tools is not None + + # With both context and tools + full_capabilities_data: dict[str, Any] = {"sampling": {"context": {}, "tools": {}}} + full_caps = ClientCapabilities.model_validate(full_capabilities_data) + assert isinstance(full_caps.sampling, SamplingCapability) + assert full_caps.sampling.context is not None + assert full_caps.sampling.tools is not None + + +def test_tool_preserves_json_schema_2020_12_fields(): + """Verify that JSON Schema 2020-12 keywords are preserved in Tool.inputSchema. + + SEP-1613 establishes JSON Schema 2020-12 as the default dialect for MCP. + This test ensures the SDK doesn't strip $schema, $defs, or additionalProperties. + """ + input_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "address": { + "type": "object", + "properties": {"street": {"type": "string"}, "city": {"type": "string"}}, + } + }, + "properties": { + "name": {"type": "string"}, + "address": {"$ref": "#/$defs/address"}, + }, + "additionalProperties": False, + } + + tool = Tool(name="test_tool", description="A test tool", inputSchema=input_schema) + + # Verify fields are preserved in the model + assert tool.inputSchema["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert "$defs" in tool.inputSchema + assert "address" in tool.inputSchema["$defs"] + assert tool.inputSchema["additionalProperties"] is False + + # Verify fields survive serialization round-trip + serialized = tool.model_dump(mode="json", by_alias=True) + assert serialized["inputSchema"]["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert "$defs" in serialized["inputSchema"] + assert serialized["inputSchema"]["additionalProperties"] is False + + +def test_list_tools_result_preserves_json_schema_2020_12_fields(): + """Verify JSON Schema 2020-12 fields survive ListToolsResult deserialization.""" + raw_response = { + "tools": [ + { + "name": "json_schema_tool", + "description": "Tool with JSON Schema 2020-12 features", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": {"item": {"type": "string"}}, + "properties": {"items": {"type": "array", "items": {"$ref": "#/$defs/item"}}}, + "additionalProperties": False, + }, + } + ] + } + + result = ListToolsResult.model_validate(raw_response) + tool = result.tools[0] + + assert tool.inputSchema["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert "$defs" in tool.inputSchema + assert tool.inputSchema["additionalProperties"] is False diff --git a/uv.lock b/uv.lock index 78f46f47cd..757709acdf 100644 --- a/uv.lock +++ b/uv.lock @@ -1,58 +1,94 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.10" -[options] -resolution-mode = "lowest-direct" - [manifest] members = [ "mcp", + "mcp-conformance-auth-client", + "mcp-everything-server", + "mcp-simple-auth", + "mcp-simple-auth-client", + "mcp-simple-chatbot", + "mcp-simple-pagination", "mcp-simple-prompt", "mcp-simple-resource", + "mcp-simple-streamablehttp", + "mcp-simple-streamablehttp-stateless", + "mcp-simple-task", + "mcp-simple-task-client", + "mcp-simple-task-interactive", + "mcp-simple-task-interactive-client", "mcp-simple-tool", + "mcp-snippets", + "mcp-sse-polling-client", + "mcp-sse-polling-demo", + "mcp-structured-output-lowlevel", ] [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] name = "anyio" -version = "4.5.0" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/44/66874c5256e9fbc30103b31927fd9341c8da6ccafd4721b2b3e81e6ef176/anyio-4.5.0.tar.gz", hash = "sha256:c5a275fe5ca0afd788001f58fca1e69e29ce706d746e317d660e21f70c530ef9", size = 169376 } + +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978, upload-time = "2024-11-30T04:30:14.439Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/68/f9e9bf6324c46e6b8396610aef90ad423ec3e18c9079547ceafea3dce0ec/anyio-4.5.0-py3-none-any.whl", hash = "sha256:fdeb095b7cc5a5563175eedd926ec4ae55413bb4be5770c424af0ba46ccb4a78", size = 89250 }, + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, ] [[package]] name = "attrs" -version = "24.3.0" +version = "25.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/c8/6260f8ccc11f0917360fc0da435c5c9c7504e3db174d5a12a1494887b045/attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 }, + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] [[package]] name = "babel" version = "2.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "backrefs" +version = "5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857, upload-time = "2025-06-22T19:34:13.97Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267, upload-time = "2025-06-22T19:34:05.252Z" }, + { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload-time = "2025-06-22T19:34:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload-time = "2025-06-22T19:34:08.172Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload-time = "2025-06-22T19:34:09.68Z" }, + { url = "https://files.pythonhosted.org/packages/fc/24/b29af34b2c9c41645a9f4ff117bae860291780d73880f449e0b5d948c070/backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9", size = 411762, upload-time = "2025-06-22T19:34:11.037Z" }, + { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, ] [[package]] @@ -68,25 +104,25 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449 } +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419 }, - { url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080 }, - { url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886 }, - { url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404 }, - { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372 }, - { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865 }, - { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699 }, - { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028 }, - { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988 }, - { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985 }, - { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816 }, - { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860 }, - { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673 }, - { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190 }, - { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926 }, - { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613 }, - { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646 }, + { url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419, upload-time = "2025-01-29T05:37:06.642Z" }, + { url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080, upload-time = "2025-01-29T05:37:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886, upload-time = "2025-01-29T04:18:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404, upload-time = "2025-01-29T04:19:04.296Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372, upload-time = "2025-01-29T05:37:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865, upload-time = "2025-01-29T05:37:14.309Z" }, + { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699, upload-time = "2025-01-29T04:18:17.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028, upload-time = "2025-01-29T04:18:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload-time = "2025-01-29T05:37:16.707Z" }, + { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload-time = "2025-01-29T05:37:18.273Z" }, + { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload-time = "2025-01-29T04:18:33.823Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload-time = "2025-01-29T04:19:12.944Z" }, + { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload-time = "2025-01-29T05:37:20.574Z" }, + { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload-time = "2025-01-29T05:37:22.106Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload-time = "2025-01-29T04:18:58.564Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload-time = "2025-01-29T04:19:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, ] [[package]] @@ -96,14 +132,14 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/c5/1a4dc131459e68a173cbdab5fad6b524f53f9c1ef7861b7698e998b837cc/cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", size = 88096 } +sdist = { url = "https://files.pythonhosted.org/packages/70/c5/1a4dc131459e68a173cbdab5fad6b524f53f9c1ef7861b7698e998b837cc/cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", size = 88096, upload-time = "2024-06-18T10:56:06.741Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f", size = 75611 }, + { url = "https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f", size = 75611, upload-time = "2024-06-18T10:55:59.489Z" }, ] [[package]] name = "cairosvg" -version = "2.7.1" +version = "2.8.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cairocffi" }, @@ -112,157 +148,336 @@ dependencies = [ { name = "pillow" }, { name = "tinycss2" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/e6/ec5900b724e3c44af7f6f51f719919137284e5da4aabe96508baec8a1b40/CairoSVG-2.7.1.tar.gz", hash = "sha256:432531d72347291b9a9ebfb6777026b607563fd8719c46ee742db0aef7271ba0", size = 8399085 } +sdist = { url = "https://files.pythonhosted.org/packages/ab/b9/5106168bd43d7cd8b7cc2a2ee465b385f14b63f4c092bb89eee2d48c8e67/cairosvg-2.8.2.tar.gz", hash = "sha256:07cbf4e86317b27a92318a4cac2a4bb37a5e9c1b8a27355d06874b22f85bef9f", size = 8398590, upload-time = "2025-05-15T06:56:32.653Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/a5/1866b42151f50453f1a0d28fc4c39f5be5f412a2e914f33449c42daafdf1/CairoSVG-2.7.1-py3-none-any.whl", hash = "sha256:8a5222d4e6c3f86f1f7046b63246877a63b49923a1cd202184c3a634ef546b3b", size = 43235 }, + { url = "https://files.pythonhosted.org/packages/67/48/816bd4aaae93dbf9e408c58598bc32f4a8c65f4b86ab560864cb3ee60adb/cairosvg-2.8.2-py3-none-any.whl", hash = "sha256:eab46dad4674f33267a671dce39b64be245911c901c70d65d2b7b0821e852bf5", size = 45773, upload-time = "2025-05-15T06:56:28.552Z" }, ] [[package]] name = "certifi" -version = "2024.12.14" +version = "2025.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, ] [[package]] name = "cffi" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, - { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, - { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, - { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, - { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, - { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, - { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, - { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, - { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, - { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, - { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, - { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, - { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, - { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, - { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, - { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, - { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, - { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, - { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, - { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, - { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, - { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, - { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, - { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, - { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, - { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, - { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, - { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 }, - { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 }, - { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 }, - { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 }, - { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 }, - { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 }, - { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 }, - { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 }, - { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 }, - { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 }, - { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 }, - { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 }, - { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 }, - { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, - { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, - { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, - { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, - { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, - { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, - { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, - { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, - { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, - { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, - { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, - { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, - { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, - { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, - { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, - { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, - { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, - { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, - { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, - { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, - { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, - { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, - { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, - { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, - { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, - { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, - { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, - { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, - { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, - { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, - { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, - { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, - { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, - { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, - { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, - { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, - { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, - { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, - { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, - { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, + { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, + { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, ] [[package]] name = "click" -version = "8.1.0" +version = "8.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/2b/7ebad1e59a99207d417c0784f7fb67893465eef84b5b47c788324f1b4095/click-8.1.0.tar.gz", hash = "sha256:977c213473c7665d3aa092b41ff12063227751c41d7b17165013e10069cc5cd2", size = 329986 } +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/3e/3a523bdd24510288b1b850428e01172116a29268378b1da9a8d0b894a115/click-8.1.0-py3-none-any.whl", hash = "sha256:19a4baa64da924c5e0cd889aba8e947f280309f1a2ce0947a3e3a7bcb7cc72d6", size = 96400 }, + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "45.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092, upload-time = "2025-07-02T13:05:01.514Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050, upload-time = "2025-07-02T13:05:11.069Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379, upload-time = "2025-07-02T13:05:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355, upload-time = "2025-07-02T13:05:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" }, + { url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050, upload-time = "2025-07-02T13:05:23.39Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224, upload-time = "2025-07-02T13:05:25.202Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143, upload-time = "2025-07-02T13:05:27.229Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299, upload-time = "2025-07-02T13:05:34.94Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558, upload-time = "2025-07-02T13:05:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020, upload-time = "2025-07-02T13:05:39.102Z" }, + { url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" }, + { url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8b/34394337abe4566848a2bd49b26bcd4b07fd466afd3e8cce4cb79a390869/cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd", size = 3575762, upload-time = "2025-07-02T13:05:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5d/a19441c1e89afb0f173ac13178606ca6fab0d3bd3ebc29e9ed1318b507fc/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097", size = 4140906, upload-time = "2025-07-02T13:05:55.914Z" }, + { url = "https://files.pythonhosted.org/packages/4b/db/daceb259982a3c2da4e619f45b5bfdec0e922a23de213b2636e78ef0919b/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e", size = 4374411, upload-time = "2025-07-02T13:05:57.814Z" }, + { url = "https://files.pythonhosted.org/packages/6a/35/5d06ad06402fc522c8bf7eab73422d05e789b4e38fe3206a85e3d6966c11/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30", size = 4140942, upload-time = "2025-07-02T13:06:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/65/79/020a5413347e44c382ef1f7f7e7a66817cd6273e3e6b5a72d18177b08b2f/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e", size = 4374079, upload-time = "2025-07-02T13:06:02.043Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c5/c0e07d84a9a2a8a0ed4f865e58f37c71af3eab7d5e094ff1b21f3f3af3bc/cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d", size = 3321362, upload-time = "2025-07-02T13:06:04.463Z" }, + { url = "https://files.pythonhosted.org/packages/c0/71/9bdbcfd58d6ff5084687fe722c58ac718ebedbc98b9f8f93781354e6d286/cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e", size = 3587878, upload-time = "2025-07-02T13:06:06.339Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/83516cfb87f4a8756eaa4203f93b283fda23d210fc14e1e594bd5f20edb6/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6", size = 4152447, upload-time = "2025-07-02T13:06:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/22/11/d2823d2a5a0bd5802b3565437add16f5c8ce1f0778bf3822f89ad2740a38/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18", size = 4386778, upload-time = "2025-07-02T13:06:10.263Z" }, + { url = "https://files.pythonhosted.org/packages/5f/38/6bf177ca6bce4fe14704ab3e93627c5b0ca05242261a2e43ef3168472540/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463", size = 4151627, upload-time = "2025-07-02T13:06:13.097Z" }, + { url = "https://files.pythonhosted.org/packages/38/6a/69fc67e5266bff68a91bcb81dff8fb0aba4d79a78521a08812048913e16f/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1", size = 4385593, upload-time = "2025-07-02T13:06:15.689Z" }, + { url = "https://files.pythonhosted.org/packages/f6/34/31a1604c9a9ade0fdab61eb48570e09a796f4d9836121266447b0eaf7feb/cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f", size = 3331106, upload-time = "2025-07-02T13:06:18.058Z" }, ] [[package]] @@ -273,36 +488,57 @@ dependencies = [ { name = "tinycss2" }, { name = "webencodings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/86/fd7f58fc498b3166f3a7e8e0cddb6e620fe1da35b02248b1bd59e95dbaaa/cssselect2-0.8.0.tar.gz", hash = "sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a", size = 35716 } +sdist = { url = "https://files.pythonhosted.org/packages/9f/86/fd7f58fc498b3166f3a7e8e0cddb6e620fe1da35b02248b1bd59e95dbaaa/cssselect2-0.8.0.tar.gz", hash = "sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a", size = 35716, upload-time = "2025-03-05T14:46:07.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/e7/aa315e6a749d9b96c2504a1ba0ba031ba2d0517e972ce22682e3fccecb09/cssselect2-0.8.0-py3-none-any.whl", hash = "sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e", size = 15454 }, + { url = "https://files.pythonhosted.org/packages/0f/e7/aa315e6a749d9b96c2504a1ba0ba031ba2d0517e972ce22682e3fccecb09/cssselect2-0.8.0-py3-none-any.whl", hash = "sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e", size = 15454, upload-time = "2025-03-05T14:46:06.463Z" }, ] [[package]] name = "defusedxml" version = "0.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "dirty-equals" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/99/133892f401ced5a27e641a473c547d5fbdb39af8f85dac8a9d633ea3e7a7/dirty_equals-0.9.0.tar.gz", hash = "sha256:17f515970b04ed7900b733c95fd8091f4f85e52f1fb5f268757f25c858eb1f7b", size = 50412, upload-time = "2025-01-11T23:23:40.491Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/0c/03cc99bf3b6328604b10829de3460f2b2ad3373200c45665c38508e550c6/dirty_equals-0.9.0-py3-none-any.whl", hash = "sha256:ff4d027f5cfa1b69573af00f7ba9043ea652dbdce3fe5cbe828e478c7346db9c", size = 28226, upload-time = "2025-01-11T23:23:37.489Z" }, ] [[package]] name = "exceptiongroup" -version = "1.2.2" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] [[package]] name = "execnet" version = "2.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524 } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612 }, + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] [[package]] @@ -312,260 +548,784 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943 } +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] [[package]] name = "griffe" -version = "1.6.2" +version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/f2/b00eb72b853ecb5bf31dd47857cdf6767e380ca24ec2910d43b3fa7cc500/griffe-1.6.2.tar.gz", hash = "sha256:3a46fa7bd83280909b63c12b9a975732a927dd97809efe5b7972290b606c5d91", size = 392836 } +sdist = { url = "https://files.pythonhosted.org/packages/ec/d7/6c09dd7ce4c7837e4cdb11dce980cb45ae3cd87677298dc3b781b6bce7d3/griffe-1.14.0.tar.gz", hash = "sha256:9d2a15c1eca966d68e00517de5d69dd1bc5c9f2335ef6c1775362ba5b8651a13", size = 424684, upload-time = "2025-09-05T15:02:29.167Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/bc/bd8b7de5e748e078b6be648e76b47189a9182b1ac1eb7791ff7969f39f27/griffe-1.6.2-py3-none-any.whl", hash = "sha256:6399f7e663150e4278a312a8e8a14d2f3d7bd86e2ef2f8056a1058e38579c2ee", size = 128638 }, + { url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439, upload-time = "2025-09-05T15:02:27.511Z" }, ] [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] name = "httpcore" -version = "1.0.7" +version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "inline-snapshot" +version = "0.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pytest" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/9e/83aaa750e9c8115d34b2d80646c1988941f2252c5548caf35aad5e529bad/inline_snapshot-0.28.0.tar.gz", hash = "sha256:6904bfc383240b6bea64de2f5d2992f04109b13def19395bdd13fb0ebcf5cf20", size = 348554, upload-time = "2025-08-24T21:48:04.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/04/190b336a006d4e1275c2dde1bf953336e818d18b779f24947579bb4ba48d/inline_snapshot-0.28.0-py3-none-any.whl", hash = "sha256:9988f82ee5e719445bbc437d0dc01e0a3c4c94f0ba910f8ad8b573cf15aa8348", size = 69026, upload-time = "2025-08-24T21:48:02.342Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "markdown" +version = "3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" }, + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "mcp" +source = { editable = "." } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] +rich = [ + { name = "rich" }, +] +ws = [ + { name = "websockets" }, +] + +[package.dev-dependencies] +dev = [ + { name = "coverage", extra = ["toml"] }, + { name = "dirty-equals" }, + { name = "inline-snapshot" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-examples" }, + { name = "pytest-flakefinder" }, + { name = "pytest-pretty" }, + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "trio" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-glightbox" }, + { name = "mkdocs-material", extra = ["imaging"] }, + { name = "mkdocstrings-python" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "httpx", specifier = ">=0.27.1" }, + { name = "httpx-sse", specifier = ">=0.4" }, + { name = "jsonschema", specifier = ">=4.20.0" }, + { name = "pydantic", specifier = ">=2.11.0,<3.0.0" }, + { name = "pydantic-settings", specifier = ">=2.5.2" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" }, + { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=310" }, + { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, + { name = "sse-starlette", specifier = ">=1.6.1" }, + { name = "starlette", specifier = ">=0.27" }, + { name = "typer", marker = "extra == 'cli'", specifier = ">=0.16.0" }, + { name = "typing-extensions", specifier = ">=4.9.0" }, + { name = "typing-inspection", specifier = ">=0.4.1" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.31.1" }, + { name = "websockets", marker = "extra == 'ws'", specifier = ">=15.0.1" }, +] +provides-extras = ["cli", "rich", "ws"] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage", extras = ["toml"], specifier = "==7.10.7" }, + { name = "dirty-equals", specifier = ">=0.9.0" }, + { name = "inline-snapshot", specifier = ">=0.23.0" }, + { name = "pyright", specifier = ">=1.1.400" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-examples", specifier = ">=0.0.14" }, + { name = "pytest-flakefinder", specifier = ">=1.1.0" }, + { name = "pytest-pretty", specifier = ">=1.2.0" }, + { name = "pytest-xdist", specifier = ">=3.6.1" }, + { name = "ruff", specifier = ">=0.8.5" }, + { name = "trio", specifier = ">=0.26.2" }, +] +docs = [ + { name = "mkdocs", specifier = ">=1.6.1" }, + { name = "mkdocs-glightbox", specifier = ">=0.4.0" }, + { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.5.45" }, + { name = "mkdocstrings-python", specifier = ">=1.12.2" }, +] + +[[package]] +name = "mcp-conformance-auth-client" +version = "0.1.0" +source = { editable = "examples/clients/conformance-auth-client" } +dependencies = [ + { name = "httpx" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "mcp", editable = "." }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.379" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-everything-server" +version = "0.1.0" +source = { editable = "examples/servers/everything-server" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp", editable = "." }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-auth" +version = "0.1.0" +source = { editable = "examples/servers/simple-auth" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "sse-starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp", editable = "." }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pydantic-settings", specifier = ">=2.5.2" }, + { name = "sse-starlette", specifier = ">=1.6.1" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.23.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.391" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "ruff", specifier = ">=0.8.5" }, +] + +[[package]] +name = "mcp-simple-auth-client" +version = "0.1.0" +source = { editable = "examples/clients/simple-auth-client" } +dependencies = [ + { name = "click" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.2.0" }, + { name = "mcp", editable = "." }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.379" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-chatbot" +version = "0.1.0" +source = { editable = "examples/clients/simple-chatbot" } +dependencies = [ + { name = "mcp" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "mcp", editable = "." }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "requests", specifier = ">=2.31.0" }, + { name = "uvicorn", specifier = ">=0.32.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.379" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-pagination" +version = "0.1.0" +source = { editable = "examples/servers/simple-pagination" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp", editable = "." }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-prompt" +version = "0.1.0" +source = { editable = "examples/servers/simple-prompt" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp", editable = "." }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-resource" +version = "0.1.0" +source = { editable = "examples/servers/simple-resource" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp", editable = "." }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-streamablehttp" +version = "0.1.0" +source = { editable = "examples/servers/simple-streamablehttp" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp", editable = "." }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-streamablehttp-stateless" +version = "0.1.0" +source = { editable = "examples/servers/simple-streamablehttp-stateless" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp", editable = "." }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, ] [[package]] -name = "httpx" -version = "0.27.0" -source = { registry = "https://pypi.org/simple" } +name = "mcp-simple-task" +version = "0.1.0" +source = { editable = "examples/servers/simple-task" } dependencies = [ { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, - { name = "sniffio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/3da5bdf4408b8b2800061c339f240c1802f2e82d55e50bd39c5a881f47f0/httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5", size = 126413 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/7b/ddacf6dcebb42466abd03f368782142baa82e08fc0c1f8eaa05b4bae87d5/httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5", size = 75590 }, + { name = "click" }, + { name = "mcp" }, + { name = "starlette" }, + { name = "uvicorn" }, ] -[[package]] -name = "httpx-sse" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "ruff" }, ] -[[package]] -name = "idna" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.0" }, + { name = "mcp", editable = "." }, + { name = "starlette" }, + { name = "uvicorn" }, ] -[[package]] -name = "iniconfig" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "ruff", specifier = ">=0.6.9" }, ] [[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } +name = "mcp-simple-task-client" +version = "0.1.0" +source = { editable = "examples/clients/simple-task-client" } dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, + { name = "click" }, + { name = "mcp" }, ] -[[package]] -name = "markdown" -version = "3.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/28/3af612670f82f4c056911fbbbb42760255801b3068c48de792d354ff4472/markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2", size = 357086 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/08/83871f3c50fc983b88547c196d11cf8c3340e37c32d2e9d6152abe2c61f7/Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803", size = 106349 }, +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "ruff" }, ] -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.0" }, + { name = "mcp", editable = "." }, ] -[[package]] -name = "markupsafe" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 }, - { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 }, - { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 }, - { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 }, - { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 }, - { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 }, - { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 }, - { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 }, - { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 }, - { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 }, - { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, - { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, - { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, - { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, - { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, - { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, - { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, - { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, - { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, - { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "ruff", specifier = ">=0.6.9" }, ] [[package]] -name = "mcp" -source = { editable = "." } +name = "mcp-simple-task-interactive" +version = "0.1.0" +source = { editable = "examples/servers/simple-task-interactive" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "sse-starlette" }, + { name = "click" }, + { name = "mcp" }, { name = "starlette" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, + { name = "uvicorn" }, ] -[package.optional-dependencies] -cli = [ - { name = "python-dotenv" }, - { name = "typer" }, +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "ruff" }, ] -rich = [ - { name = "rich" }, + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.0" }, + { name = "mcp", editable = "." }, + { name = "starlette" }, + { name = "uvicorn" }, ] -ws = [ - { name = "websockets" }, + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-task-interactive-client" +version = "0.1.0" +source = { editable = "examples/clients/simple-task-interactive-client" } +dependencies = [ + { name = "click" }, + { name = "mcp" }, ] [package.dev-dependencies] dev = [ { name = "pyright" }, - { name = "pytest" }, - { name = "pytest-examples" }, - { name = "pytest-flakefinder" }, - { name = "pytest-xdist" }, { name = "ruff" }, - { name = "trio" }, -] -docs = [ - { name = "mkdocs" }, - { name = "mkdocs-glightbox" }, - { name = "mkdocs-material", extra = ["imaging"] }, - { name = "mkdocstrings-python" }, ] [package.metadata] requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "httpx-sse", specifier = ">=0.4" }, - { name = "pydantic", specifier = ">=2.7.2,<3.0.0" }, - { name = "pydantic-settings", specifier = ">=2.5.2" }, - { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, - { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, - { name = "sse-starlette", specifier = ">=1.6.1" }, - { name = "starlette", specifier = ">=0.27" }, - { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12.4" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.23.1" }, - { name = "websockets", marker = "extra == 'ws'", specifier = ">=15.0.1" }, + { name = "click", specifier = ">=8.0" }, + { name = "mcp", editable = "." }, ] -provides-extras = ["cli", "rich", "ws"] [package.metadata.requires-dev] dev = [ - { name = "pyright", specifier = ">=1.1.391" }, - { name = "pytest", specifier = ">=8.3.4" }, - { name = "pytest-examples", specifier = ">=0.0.14" }, - { name = "pytest-flakefinder", specifier = ">=1.1.0" }, - { name = "pytest-xdist", specifier = ">=3.6.1" }, - { name = "ruff", specifier = ">=0.8.5" }, - { name = "trio", specifier = ">=0.26.2" }, -] -docs = [ - { name = "mkdocs", specifier = ">=1.6.1" }, - { name = "mkdocs-glightbox", specifier = ">=0.4.0" }, - { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.5.45" }, - { name = "mkdocstrings-python", specifier = ">=1.12.2" }, + { name = "pyright", specifier = ">=1.1.378" }, + { name = "ruff", specifier = ">=0.6.9" }, ] [[package]] -name = "mcp-simple-prompt" +name = "mcp-simple-tool" version = "0.1.0" -source = { editable = "examples/servers/simple-prompt" } +source = { editable = "examples/servers/simple-tool" } dependencies = [ { name = "anyio" }, { name = "click" }, @@ -583,7 +1343,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.1.0" }, + { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, { name = "mcp", editable = "." }, ] @@ -596,13 +1356,22 @@ dev = [ ] [[package]] -name = "mcp-simple-resource" +name = "mcp-snippets" version = "0.1.0" -source = { editable = "examples/servers/simple-resource" } +source = { editable = "examples/snippets" } +dependencies = [ + { name = "mcp" }, +] + +[package.metadata] +requires-dist = [{ name = "mcp", editable = "." }] + +[[package]] +name = "mcp-sse-polling-client" +version = "0.1.0" +source = { editable = "examples/clients/sse-polling-client" } dependencies = [ - { name = "anyio" }, { name = "click" }, - { name = "httpx" }, { name = "mcp" }, ] @@ -615,9 +1384,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.1.0" }, - { name = "httpx", specifier = ">=0.27" }, + { name = "click", specifier = ">=8.2.0" }, { name = "mcp", editable = "." }, ] @@ -629,14 +1396,16 @@ dev = [ ] [[package]] -name = "mcp-simple-tool" +name = "mcp-sse-polling-demo" version = "0.1.0" -source = { editable = "examples/servers/simple-tool" } +source = { editable = "examples/servers/sse-polling-demo" } dependencies = [ { name = "anyio" }, { name = "click" }, { name = "httpx" }, { name = "mcp" }, + { name = "starlette" }, + { name = "uvicorn" }, ] [package.dev-dependencies] @@ -649,9 +1418,11 @@ dev = [ [package.metadata] requires-dist = [ { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.1.0" }, + { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, { name = "mcp", editable = "." }, + { name = "starlette" }, + { name = "uvicorn" }, ] [package.metadata.requires-dev] @@ -661,22 +1432,33 @@ dev = [ { name = "ruff", specifier = ">=0.6.9" }, ] +[[package]] +name = "mcp-structured-output-lowlevel" +version = "0.1.0" +source = { virtual = "examples/servers/structured-output-lowlevel" } +dependencies = [ + { name = "mcp" }, +] + +[package.metadata] +requires-dist = [{ name = "mcp", editable = "." }] + [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] name = "mergedeep" version = "1.3.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661 } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354 }, + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] [[package]] @@ -698,23 +1480,23 @@ dependencies = [ { name = "pyyaml-env-tag" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451 }, + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, ] [[package]] name = "mkdocs-autorefs" -version = "1.4.1" +version = "1.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "markupsafe" }, { name = "mkdocs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/44/140469d87379c02f1e1870315f3143718036a983dd0416650827b8883192/mkdocs_autorefs-1.4.1.tar.gz", hash = "sha256:4b5b6235a4becb2b10425c2fa191737e415b37aa3418919db33e5d774c9db079", size = 4131355 } +sdist = { url = "https://files.pythonhosted.org/packages/51/fa/9124cd63d822e2bcbea1450ae68cdc3faf3655c69b455f3a7ed36ce6c628/mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75", size = 55425, upload-time = "2025-08-26T14:23:17.223Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/29/1125f7b11db63e8e32bcfa0752a4eea30abff3ebd0796f808e14571ddaa2/mkdocs_autorefs-1.4.1-py3-none-any.whl", hash = "sha256:9793c5ac06a6ebbe52ec0f8439256e66187badf4b5334b5fde0b128ec134df4f", size = 5782047 }, + { url = "https://files.pythonhosted.org/packages/9f/4d/7123b6fa2278000688ebd338e2a06d16870aaf9eceae6ba047ea05f92df1/mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", size = 25034, upload-time = "2025-08-26T14:23:15.906Z" }, ] [[package]] @@ -726,26 +1508,31 @@ dependencies = [ { name = "platformdirs" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239 } +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521 }, + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, ] [[package]] name = "mkdocs-glightbox" -version = "0.4.0" +version = "0.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/5a/0bc456397ba0acc684b5b1daa4ca232ed717938fd37198251d8bcc4053bf/mkdocs-glightbox-0.4.0.tar.gz", hash = "sha256:392b34207bf95991071a16d5f8916d1d2f2cd5d5bb59ae2997485ccd778c70d9", size = 32010 } +dependencies = [ + { name = "selectolax" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/72/c03e9d8d2dbe098d7ce5d51309933a1d3aea268965ed097ab16f4b54de15/mkdocs_glightbox-0.5.1.tar.gz", hash = "sha256:7d78a5b045f2479f61b0bbb17742ba701755c56b013e70ac189c9d87a91e80bf", size = 480028, upload-time = "2025-09-04T13:10:29.679Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/72/b0c2128bb569c732c11ae8e49a777089e77d83c05946062caa19b841e6fb/mkdocs_glightbox-0.4.0-py3-none-any.whl", hash = "sha256:e0107beee75d3eb7380ac06ea2d6eac94c999eaa49f8c3cbab0e7be2ac006ccf", size = 31154 }, + { url = "https://files.pythonhosted.org/packages/30/cf/e9a0ce9da269746906fdc595c030f6df66793dad1487abd1699af2ba44f1/mkdocs_glightbox-0.5.1-py3-none-any.whl", hash = "sha256:f47af0daff164edf8d36e553338425be3aab6e34b987d9cbbc2ae7819a98cb01", size = 26431, upload-time = "2025-09-04T13:10:27.933Z" }, ] [[package]] name = "mkdocs-material" -version = "9.5.45" +version = "9.6.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, + { name = "backrefs" }, + { name = "click" }, { name = "colorama" }, { name = "jinja2" }, { name = "markdown" }, @@ -754,12 +1541,11 @@ dependencies = [ { name = "paginate" }, { name = "pygments" }, { name = "pymdown-extensions" }, - { name = "regex" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/02/38f1f76252462b8e9652eb3778905206c1f3b9b4c25bf60aafc029675a2b/mkdocs_material-9.5.45.tar.gz", hash = "sha256:286489cf0beca4a129d91d59d6417419c63bceed1ce5cd0ec1fc7e1ebffb8189", size = 3906694 } +sdist = { url = "https://files.pythonhosted.org/packages/44/94/eb0fca39b19c2251b16bc759860a50f232655c4377116fa9c0e7db11b82c/mkdocs_material-9.6.19.tar.gz", hash = "sha256:80e7b3f9acabfee9b1f68bd12c26e59c865b3d5bbfb505fd1344e970db02c4aa", size = 4038202, upload-time = "2025-09-07T17:46:40.468Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/43/f5f866cd840e14f82068831e53446ea1f66a128cd38a229c5b9c9243ed9e/mkdocs_material-9.5.45-py3-none-any.whl", hash = "sha256:a9be237cfd0be14be75f40f1726d83aa3a81ce44808dc3594d47a7a592f44547", size = 8615700 }, + { url = "https://files.pythonhosted.org/packages/02/23/a2551d1038bedc2771366f65ff3680bb3a89674cd7ca6140850c859f1f71/mkdocs_material-9.6.19-py3-none-any.whl", hash = "sha256:7492d2ac81952a467ca8a10cac915d6ea5c22876932f44b5a0f4f8e7d68ac06f", size = 9240205, upload-time = "2025-09-07T17:46:36.484Z" }, ] [package.optional-dependencies] @@ -772,14 +1558,14 @@ imaging = [ name = "mkdocs-material-extensions" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847 } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728 }, + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] [[package]] name = "mkdocstrings" -version = "0.29.0" +version = "0.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, @@ -789,41 +1575,42 @@ dependencies = [ { name = "mkdocs-autorefs" }, { name = "pymdown-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/4d/a9484dc5d926295bdf308f1f6c4f07fcc99735b970591edc414d401fcc91/mkdocstrings-0.29.0.tar.gz", hash = "sha256:3657be1384543ce0ee82112c3e521bbf48e41303aa0c229b9ffcccba057d922e", size = 1212185 } +sdist = { url = "https://files.pythonhosted.org/packages/e2/0a/7e4776217d4802009c8238c75c5345e23014a4706a8414a62c0498858183/mkdocstrings-0.30.0.tar.gz", hash = "sha256:5d8019b9c31ddacd780b6784ffcdd6f21c408f34c0bd1103b5351d609d5b4444", size = 106597, upload-time = "2025-07-22T23:48:45.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/47/eb876dfd84e48f31ff60897d161b309cf6a04ca270155b0662aae562b3fb/mkdocstrings-0.29.0-py3-none-any.whl", hash = "sha256:8ea98358d2006f60befa940fdebbbc88a26b37ecbcded10be726ba359284f73d", size = 1630824 }, + { url = "https://files.pythonhosted.org/packages/de/b4/3c5eac68f31e124a55d255d318c7445840fa1be55e013f507556d6481913/mkdocstrings-0.30.0-py3-none-any.whl", hash = "sha256:ae9e4a0d8c1789697ac776f2e034e2ddd71054ae1cf2c2bb1433ccfd07c226f2", size = 36579, upload-time = "2025-07-22T23:48:44.152Z" }, ] [[package]] name = "mkdocstrings-python" -version = "1.12.2" +version = "1.18.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffe" }, { name = "mkdocs-autorefs" }, { name = "mkdocstrings" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/ec/cb6debe2db77f1ef42b25b21d93b5021474de3037cd82385e586aee72545/mkdocstrings_python-1.12.2.tar.gz", hash = "sha256:7a1760941c0b52a2cd87b960a9e21112ffe52e7df9d0b9583d04d47ed2e186f3", size = 168207 } +sdist = { url = "https://files.pythonhosted.org/packages/95/ae/58ab2bfbee2792e92a98b97e872f7c003deb903071f75d8d83aa55db28fa/mkdocstrings_python-1.18.2.tar.gz", hash = "sha256:4ad536920a07b6336f50d4c6d5603316fafb1172c5c882370cbbc954770ad323", size = 207972, upload-time = "2025-08-28T16:11:19.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/c1/ac524e1026d9580cbc654b5d19f5843c8b364a66d30f956372cd09fd2f92/mkdocstrings_python-1.12.2-py3-none-any.whl", hash = "sha256:7f7d40d6db3cb1f5d19dbcd80e3efe4d0ba32b073272c0c0de9de2e604eda62a", size = 111759 }, + { url = "https://files.pythonhosted.org/packages/d5/8f/ce008599d9adebf33ed144e7736914385e8537f5fc686fdb7cceb8c22431/mkdocstrings_python-1.18.2-py3-none-any.whl", hash = "sha256:944fe6deb8f08f33fa936d538233c4036e9f53e840994f6146e8e94eb71b600d", size = 138215, upload-time = "2025-08-28T16:11:18.176Z" }, ] [[package]] name = "mypy-extensions" -version = "1.0.0" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "nodeenv" version = "1.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] [[package]] @@ -833,264 +1620,335 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060 } +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692 }, + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, ] [[package]] name = "packaging" -version = "24.2" +version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] name = "paginate" version = "0.5.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252 } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 }, + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, ] [[package]] name = "pathspec" version = "0.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] [[package]] name = "pillow" -version = "10.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/69/a31cccd538ca0b5272be2a38347f8839b97a14be104ea08b0db92f749c74/pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e", size = 3509271 }, - { url = "https://files.pythonhosted.org/packages/9a/9e/4143b907be8ea0bce215f2ae4f7480027473f8b61fcedfda9d851082a5d2/pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d", size = 3375658 }, - { url = "https://files.pythonhosted.org/packages/8a/25/1fc45761955f9359b1169aa75e241551e74ac01a09f487adaaf4c3472d11/pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856", size = 4332075 }, - { url = "https://files.pythonhosted.org/packages/5e/dd/425b95d0151e1d6c951f45051112394f130df3da67363b6bc75dc4c27aba/pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f", size = 4444808 }, - { url = "https://files.pythonhosted.org/packages/b1/84/9a15cc5726cbbfe7f9f90bfb11f5d028586595907cd093815ca6644932e3/pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b", size = 4356290 }, - { url = "https://files.pythonhosted.org/packages/b5/5b/6651c288b08df3b8c1e2f8c1152201e0b25d240e22ddade0f1e242fc9fa0/pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc", size = 4525163 }, - { url = "https://files.pythonhosted.org/packages/07/8b/34854bf11a83c248505c8cb0fcf8d3d0b459a2246c8809b967963b6b12ae/pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e", size = 4463100 }, - { url = "https://files.pythonhosted.org/packages/78/63/0632aee4e82476d9cbe5200c0cdf9ba41ee04ed77887432845264d81116d/pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46", size = 4592880 }, - { url = "https://files.pythonhosted.org/packages/df/56/b8663d7520671b4398b9d97e1ed9f583d4afcbefbda3c6188325e8c297bd/pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984", size = 2235218 }, - { url = "https://files.pythonhosted.org/packages/f4/72/0203e94a91ddb4a9d5238434ae6c1ca10e610e8487036132ea9bf806ca2a/pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141", size = 2554487 }, - { url = "https://files.pythonhosted.org/packages/bd/52/7e7e93d7a6e4290543f17dc6f7d3af4bd0b3dd9926e2e8a35ac2282bc5f4/pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1", size = 2243219 }, - { url = "https://files.pythonhosted.org/packages/a7/62/c9449f9c3043c37f73e7487ec4ef0c03eb9c9afc91a92b977a67b3c0bbc5/pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c", size = 3509265 }, - { url = "https://files.pythonhosted.org/packages/f4/5f/491dafc7bbf5a3cc1845dc0430872e8096eb9e2b6f8161509d124594ec2d/pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be", size = 3375655 }, - { url = "https://files.pythonhosted.org/packages/73/d5/c4011a76f4207a3c151134cd22a1415741e42fa5ddecec7c0182887deb3d/pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3", size = 4340304 }, - { url = "https://files.pythonhosted.org/packages/ac/10/c67e20445a707f7a610699bba4fe050583b688d8cd2d202572b257f46600/pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6", size = 4452804 }, - { url = "https://files.pythonhosted.org/packages/a9/83/6523837906d1da2b269dee787e31df3b0acb12e3d08f024965a3e7f64665/pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe", size = 4365126 }, - { url = "https://files.pythonhosted.org/packages/ba/e5/8c68ff608a4203085158cff5cc2a3c534ec384536d9438c405ed6370d080/pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319", size = 4533541 }, - { url = "https://files.pythonhosted.org/packages/f4/7c/01b8dbdca5bc6785573f4cee96e2358b0918b7b2c7b60d8b6f3abf87a070/pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d", size = 4471616 }, - { url = "https://files.pythonhosted.org/packages/c8/57/2899b82394a35a0fbfd352e290945440e3b3785655a03365c0ca8279f351/pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696", size = 4600802 }, - { url = "https://files.pythonhosted.org/packages/4d/d7/a44f193d4c26e58ee5d2d9db3d4854b2cfb5b5e08d360a5e03fe987c0086/pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496", size = 2235213 }, - { url = "https://files.pythonhosted.org/packages/c1/d0/5866318eec2b801cdb8c82abf190c8343d8a1cd8bf5a0c17444a6f268291/pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91", size = 2554498 }, - { url = "https://files.pythonhosted.org/packages/d4/c8/310ac16ac2b97e902d9eb438688de0d961660a87703ad1561fd3dfbd2aa0/pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22", size = 2243219 }, - { url = "https://files.pythonhosted.org/packages/05/cb/0353013dc30c02a8be34eb91d25e4e4cf594b59e5a55ea1128fde1e5f8ea/pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94", size = 3509350 }, - { url = "https://files.pythonhosted.org/packages/e7/cf/5c558a0f247e0bf9cec92bff9b46ae6474dd736f6d906315e60e4075f737/pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597", size = 3374980 }, - { url = "https://files.pythonhosted.org/packages/84/48/6e394b86369a4eb68b8a1382c78dc092245af517385c086c5094e3b34428/pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80", size = 4343799 }, - { url = "https://files.pythonhosted.org/packages/3b/f3/a8c6c11fa84b59b9df0cd5694492da8c039a24cd159f0f6918690105c3be/pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca", size = 4459973 }, - { url = "https://files.pythonhosted.org/packages/7d/1b/c14b4197b80150fb64453585247e6fb2e1d93761fa0fa9cf63b102fde822/pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef", size = 4370054 }, - { url = "https://files.pythonhosted.org/packages/55/77/40daddf677897a923d5d33329acd52a2144d54a9644f2a5422c028c6bf2d/pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a", size = 4539484 }, - { url = "https://files.pythonhosted.org/packages/40/54/90de3e4256b1207300fb2b1d7168dd912a2fb4b2401e439ba23c2b2cabde/pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b", size = 4477375 }, - { url = "https://files.pythonhosted.org/packages/13/24/1bfba52f44193860918ff7c93d03d95e3f8748ca1de3ceaf11157a14cf16/pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9", size = 4608773 }, - { url = "https://files.pythonhosted.org/packages/55/04/5e6de6e6120451ec0c24516c41dbaf80cce1b6451f96561235ef2429da2e/pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42", size = 2235690 }, - { url = "https://files.pythonhosted.org/packages/74/0a/d4ce3c44bca8635bd29a2eab5aa181b654a734a29b263ca8efe013beea98/pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a", size = 2554951 }, - { url = "https://files.pythonhosted.org/packages/b5/ca/184349ee40f2e92439be9b3502ae6cfc43ac4b50bc4fc6b3de7957563894/pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9", size = 2243427 }, - { url = "https://files.pythonhosted.org/packages/c3/00/706cebe7c2c12a6318aabe5d354836f54adff7156fd9e1bd6c89f4ba0e98/pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3", size = 3525685 }, - { url = "https://files.pythonhosted.org/packages/cf/76/f658cbfa49405e5ecbfb9ba42d07074ad9792031267e782d409fd8fe7c69/pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb", size = 3374883 }, - { url = "https://files.pythonhosted.org/packages/46/2b/99c28c4379a85e65378211971c0b430d9c7234b1ec4d59b2668f6299e011/pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70", size = 4339837 }, - { url = "https://files.pythonhosted.org/packages/f1/74/b1ec314f624c0c43711fdf0d8076f82d9d802afd58f1d62c2a86878e8615/pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be", size = 4455562 }, - { url = "https://files.pythonhosted.org/packages/4a/2a/4b04157cb7b9c74372fa867096a1607e6fedad93a44deeff553ccd307868/pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0", size = 4366761 }, - { url = "https://files.pythonhosted.org/packages/ac/7b/8f1d815c1a6a268fe90481232c98dd0e5fa8c75e341a75f060037bd5ceae/pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc", size = 4536767 }, - { url = "https://files.pythonhosted.org/packages/e5/77/05fa64d1f45d12c22c314e7b97398ffb28ef2813a485465017b7978b3ce7/pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a", size = 4477989 }, - { url = "https://files.pythonhosted.org/packages/12/63/b0397cfc2caae05c3fb2f4ed1b4fc4fc878f0243510a7a6034ca59726494/pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309", size = 4610255 }, - { url = "https://files.pythonhosted.org/packages/7b/f9/cfaa5082ca9bc4a6de66ffe1c12c2d90bf09c309a5f52b27759a596900e7/pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060", size = 2235603 }, - { url = "https://files.pythonhosted.org/packages/01/6a/30ff0eef6e0c0e71e55ded56a38d4859bf9d3634a94a88743897b5f96936/pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea", size = 2554972 }, - { url = "https://files.pythonhosted.org/packages/48/2c/2e0a52890f269435eee38b21c8218e102c621fe8d8df8b9dd06fabf879ba/pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", size = 2243375 }, - { url = "https://files.pythonhosted.org/packages/38/30/095d4f55f3a053392f75e2eae45eba3228452783bab3d9a920b951ac495c/pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4", size = 3493889 }, - { url = "https://files.pythonhosted.org/packages/f3/e8/4ff79788803a5fcd5dc35efdc9386af153569853767bff74540725b45863/pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da", size = 3346160 }, - { url = "https://files.pythonhosted.org/packages/d7/ac/4184edd511b14f760c73f5bb8a5d6fd85c591c8aff7c2229677a355c4179/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026", size = 3435020 }, - { url = "https://files.pythonhosted.org/packages/da/21/1749cd09160149c0a246a81d646e05f35041619ce76f6493d6a96e8d1103/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e", size = 3490539 }, - { url = "https://files.pythonhosted.org/packages/b6/f5/f71fe1888b96083b3f6dfa0709101f61fc9e972c0c8d04e9d93ccef2a045/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5", size = 3476125 }, - { url = "https://files.pythonhosted.org/packages/96/b9/c0362c54290a31866c3526848583a2f45a535aa9d725fd31e25d318c805f/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885", size = 3579373 }, - { url = "https://files.pythonhosted.org/packages/52/3b/ce7a01026a7cf46e5452afa86f97a5e88ca97f562cafa76570178ab56d8d/pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5", size = 2554661 }, +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554, upload-time = "2025-07-01T09:13:39.342Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548, upload-time = "2025-07-01T09:13:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742, upload-time = "2025-07-03T13:09:47.439Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087, upload-time = "2025-07-03T13:09:51.796Z" }, + { url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350, upload-time = "2025-07-01T09:13:43.865Z" }, + { url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840, upload-time = "2025-07-01T09:13:46.161Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005, upload-time = "2025-07-01T09:13:47.829Z" }, + { url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372, upload-time = "2025-07-01T09:13:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090, upload-time = "2025-07-01T09:13:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988, upload-time = "2025-07-01T09:13:55.699Z" }, + { url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899, upload-time = "2025-07-01T09:13:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, + { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, + { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556, upload-time = "2025-07-01T09:16:09.961Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625, upload-time = "2025-07-01T09:16:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207, upload-time = "2025-07-03T13:11:10.201Z" }, + { url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939, upload-time = "2025-07-03T13:11:15.68Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166, upload-time = "2025-07-01T09:16:13.74Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482, upload-time = "2025-07-01T09:16:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596, upload-time = "2025-07-01T09:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, + { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, + { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, + { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, ] [[package]] name = "platformdirs" -version = "4.3.6" +version = "4.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, ] [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pycparser" -version = "2.22" +version = "2.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, ] [[package]] name = "pydantic" -version = "2.10.1" +version = "2.11.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/bd/7fc610993f616d2398958d0028d15eaf53bde5f80cb2edb7aa4f1feaf3a7/pydantic-2.10.1.tar.gz", hash = "sha256:a4daca2dc0aa429555e0656d6bf94873a7dc5f54ee42b1f5873d666fb3f35560", size = 783717 } +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/fc/fda48d347bd50a788dd2a0f318a52160f911b86fc2d8b4c86f4d7c9bceea/pydantic-2.10.1-py3-none-any.whl", hash = "sha256:a8d20db84de64cf4a7d59e899c2caf0fe9d660c7cfc482528e7020d7dd189a7e", size = 455329 }, + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, ] [[package]] name = "pydantic-core" -version = "2.27.1" +version = "2.33.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/9f/7de1f19b6aea45aeb441838782d68352e71bfa98ee6fa048d5041991b33e/pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235", size = 412785 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/ce/60fd96895c09738648c83f3f00f595c807cb6735c70d3306b548cc96dd49/pydantic_core-2.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a", size = 1897984 }, - { url = "https://files.pythonhosted.org/packages/fd/b9/84623d6b6be98cc209b06687d9bca5a7b966ffed008d15225dd0d20cce2e/pydantic_core-2.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b", size = 1807491 }, - { url = "https://files.pythonhosted.org/packages/01/72/59a70165eabbc93b1111d42df9ca016a4aa109409db04304829377947028/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278", size = 1831953 }, - { url = "https://files.pythonhosted.org/packages/7c/0c/24841136476adafd26f94b45bb718a78cb0500bd7b4f8d667b67c29d7b0d/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05", size = 1856071 }, - { url = "https://files.pythonhosted.org/packages/53/5e/c32957a09cceb2af10d7642df45d1e3dbd8596061f700eac93b801de53c0/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4", size = 2038439 }, - { url = "https://files.pythonhosted.org/packages/e4/8f/979ab3eccd118b638cd6d8f980fea8794f45018255a36044dea40fe579d4/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f", size = 2787416 }, - { url = "https://files.pythonhosted.org/packages/02/1d/00f2e4626565b3b6d3690dab4d4fe1a26edd6a20e53749eb21ca892ef2df/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08", size = 2134548 }, - { url = "https://files.pythonhosted.org/packages/9d/46/3112621204128b90898adc2e721a3cd6cf5626504178d6f32c33b5a43b79/pydantic_core-2.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6", size = 1989882 }, - { url = "https://files.pythonhosted.org/packages/49/ec/557dd4ff5287ffffdf16a31d08d723de6762bb1b691879dc4423392309bc/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807", size = 1995829 }, - { url = "https://files.pythonhosted.org/packages/6e/b2/610dbeb74d8d43921a7234555e4c091cb050a2bdb8cfea86d07791ce01c5/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c", size = 2091257 }, - { url = "https://files.pythonhosted.org/packages/8c/7f/4bf8e9d26a9118521c80b229291fa9558a07cdd9a968ec2d5c1026f14fbc/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206", size = 2143894 }, - { url = "https://files.pythonhosted.org/packages/1f/1c/875ac7139c958f4390f23656fe696d1acc8edf45fb81e4831960f12cd6e4/pydantic_core-2.27.1-cp310-none-win32.whl", hash = "sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c", size = 1816081 }, - { url = "https://files.pythonhosted.org/packages/d7/41/55a117acaeda25ceae51030b518032934f251b1dac3704a53781383e3491/pydantic_core-2.27.1-cp310-none-win_amd64.whl", hash = "sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17", size = 1981109 }, - { url = "https://files.pythonhosted.org/packages/27/39/46fe47f2ad4746b478ba89c561cafe4428e02b3573df882334bd2964f9cb/pydantic_core-2.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8", size = 1895553 }, - { url = "https://files.pythonhosted.org/packages/1c/00/0804e84a78b7fdb394fff4c4f429815a10e5e0993e6ae0e0b27dd20379ee/pydantic_core-2.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330", size = 1807220 }, - { url = "https://files.pythonhosted.org/packages/01/de/df51b3bac9820d38371f5a261020f505025df732ce566c2a2e7970b84c8c/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52", size = 1829727 }, - { url = "https://files.pythonhosted.org/packages/5f/d9/c01d19da8f9e9fbdb2bf99f8358d145a312590374d0dc9dd8dbe484a9cde/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4", size = 1854282 }, - { url = "https://files.pythonhosted.org/packages/5f/84/7db66eb12a0dc88c006abd6f3cbbf4232d26adfd827a28638c540d8f871d/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c", size = 2037437 }, - { url = "https://files.pythonhosted.org/packages/34/ac/a2537958db8299fbabed81167d58cc1506049dba4163433524e06a7d9f4c/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de", size = 2780899 }, - { url = "https://files.pythonhosted.org/packages/4a/c1/3e38cd777ef832c4fdce11d204592e135ddeedb6c6f525478a53d1c7d3e5/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025", size = 2135022 }, - { url = "https://files.pythonhosted.org/packages/7a/69/b9952829f80fd555fe04340539d90e000a146f2a003d3fcd1e7077c06c71/pydantic_core-2.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e", size = 1987969 }, - { url = "https://files.pythonhosted.org/packages/05/72/257b5824d7988af43460c4e22b63932ed651fe98804cc2793068de7ec554/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919", size = 1994625 }, - { url = "https://files.pythonhosted.org/packages/73/c3/78ed6b7f3278a36589bcdd01243189ade7fc9b26852844938b4d7693895b/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c", size = 2090089 }, - { url = "https://files.pythonhosted.org/packages/8d/c8/b4139b2f78579960353c4cd987e035108c93a78371bb19ba0dc1ac3b3220/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc", size = 2142496 }, - { url = "https://files.pythonhosted.org/packages/3e/f8/171a03e97eb36c0b51981efe0f78460554a1d8311773d3d30e20c005164e/pydantic_core-2.27.1-cp311-none-win32.whl", hash = "sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9", size = 1811758 }, - { url = "https://files.pythonhosted.org/packages/6a/fe/4e0e63c418c1c76e33974a05266e5633e879d4061f9533b1706a86f77d5b/pydantic_core-2.27.1-cp311-none-win_amd64.whl", hash = "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5", size = 1980864 }, - { url = "https://files.pythonhosted.org/packages/50/fc/93f7238a514c155a8ec02fc7ac6376177d449848115e4519b853820436c5/pydantic_core-2.27.1-cp311-none-win_arm64.whl", hash = "sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89", size = 1864327 }, - { url = "https://files.pythonhosted.org/packages/be/51/2e9b3788feb2aebff2aa9dfbf060ec739b38c05c46847601134cc1fed2ea/pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f", size = 1895239 }, - { url = "https://files.pythonhosted.org/packages/7b/9e/f8063952e4a7d0127f5d1181addef9377505dcce3be224263b25c4f0bfd9/pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02", size = 1805070 }, - { url = "https://files.pythonhosted.org/packages/2c/9d/e1d6c4561d262b52e41b17a7ef8301e2ba80b61e32e94520271029feb5d8/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c", size = 1828096 }, - { url = "https://files.pythonhosted.org/packages/be/65/80ff46de4266560baa4332ae3181fffc4488ea7d37282da1a62d10ab89a4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac", size = 1857708 }, - { url = "https://files.pythonhosted.org/packages/d5/ca/3370074ad758b04d9562b12ecdb088597f4d9d13893a48a583fb47682cdf/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb", size = 2037751 }, - { url = "https://files.pythonhosted.org/packages/b1/e2/4ab72d93367194317b99d051947c071aef6e3eb95f7553eaa4208ecf9ba4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529", size = 2733863 }, - { url = "https://files.pythonhosted.org/packages/8a/c6/8ae0831bf77f356bb73127ce5a95fe115b10f820ea480abbd72d3cc7ccf3/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35", size = 2161161 }, - { url = "https://files.pythonhosted.org/packages/f1/f4/b2fe73241da2429400fc27ddeaa43e35562f96cf5b67499b2de52b528cad/pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089", size = 1993294 }, - { url = "https://files.pythonhosted.org/packages/77/29/4bb008823a7f4cc05828198153f9753b3bd4c104d93b8e0b1bfe4e187540/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381", size = 2001468 }, - { url = "https://files.pythonhosted.org/packages/f2/a9/0eaceeba41b9fad851a4107e0cf999a34ae8f0d0d1f829e2574f3d8897b0/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb", size = 2091413 }, - { url = "https://files.pythonhosted.org/packages/d8/36/eb8697729725bc610fd73940f0d860d791dc2ad557faaefcbb3edbd2b349/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae", size = 2154735 }, - { url = "https://files.pythonhosted.org/packages/52/e5/4f0fbd5c5995cc70d3afed1b5c754055bb67908f55b5cb8000f7112749bf/pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c", size = 1833633 }, - { url = "https://files.pythonhosted.org/packages/ee/f2/c61486eee27cae5ac781305658779b4a6b45f9cc9d02c90cb21b940e82cc/pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16", size = 1986973 }, - { url = "https://files.pythonhosted.org/packages/df/a6/e3f12ff25f250b02f7c51be89a294689d175ac76e1096c32bf278f29ca1e/pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e", size = 1883215 }, - { url = "https://files.pythonhosted.org/packages/0f/d6/91cb99a3c59d7b072bded9959fbeab0a9613d5a4935773c0801f1764c156/pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073", size = 1895033 }, - { url = "https://files.pythonhosted.org/packages/07/42/d35033f81a28b27dedcade9e967e8a40981a765795c9ebae2045bcef05d3/pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08", size = 1807542 }, - { url = "https://files.pythonhosted.org/packages/41/c2/491b59e222ec7e72236e512108ecad532c7f4391a14e971c963f624f7569/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf", size = 1827854 }, - { url = "https://files.pythonhosted.org/packages/e3/f3/363652651779113189cefdbbb619b7b07b7a67ebb6840325117cc8cc3460/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737", size = 1857389 }, - { url = "https://files.pythonhosted.org/packages/5f/97/be804aed6b479af5a945daec7538d8bf358d668bdadde4c7888a2506bdfb/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2", size = 2037934 }, - { url = "https://files.pythonhosted.org/packages/42/01/295f0bd4abf58902917e342ddfe5f76cf66ffabfc57c2e23c7681a1a1197/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107", size = 2735176 }, - { url = "https://files.pythonhosted.org/packages/9d/a0/cd8e9c940ead89cc37812a1a9f310fef59ba2f0b22b4e417d84ab09fa970/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51", size = 2160720 }, - { url = "https://files.pythonhosted.org/packages/73/ae/9d0980e286627e0aeca4c352a60bd760331622c12d576e5ea4441ac7e15e/pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a", size = 1992972 }, - { url = "https://files.pythonhosted.org/packages/bf/ba/ae4480bc0292d54b85cfb954e9d6bd226982949f8316338677d56541b85f/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc", size = 2001477 }, - { url = "https://files.pythonhosted.org/packages/55/b7/e26adf48c2f943092ce54ae14c3c08d0d221ad34ce80b18a50de8ed2cba8/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960", size = 2091186 }, - { url = "https://files.pythonhosted.org/packages/ba/cc/8491fff5b608b3862eb36e7d29d36a1af1c945463ca4c5040bf46cc73f40/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23", size = 2154429 }, - { url = "https://files.pythonhosted.org/packages/78/d8/c080592d80edd3441ab7f88f865f51dae94a157fc64283c680e9f32cf6da/pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05", size = 1833713 }, - { url = "https://files.pythonhosted.org/packages/83/84/5ab82a9ee2538ac95a66e51f6838d6aba6e0a03a42aa185ad2fe404a4e8f/pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337", size = 1987897 }, - { url = "https://files.pythonhosted.org/packages/df/c3/b15fb833926d91d982fde29c0624c9f225da743c7af801dace0d4e187e71/pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", size = 1882983 }, - { url = "https://files.pythonhosted.org/packages/7c/60/e5eb2d462595ba1f622edbe7b1d19531e510c05c405f0b87c80c1e89d5b1/pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6", size = 1894016 }, - { url = "https://files.pythonhosted.org/packages/61/20/da7059855225038c1c4326a840908cc7ca72c7198cb6addb8b92ec81c1d6/pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676", size = 1771648 }, - { url = "https://files.pythonhosted.org/packages/8f/fc/5485cf0b0bb38da31d1d292160a4d123b5977841ddc1122c671a30b76cfd/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d", size = 1826929 }, - { url = "https://files.pythonhosted.org/packages/a1/ff/fb1284a210e13a5f34c639efc54d51da136074ffbe25ec0c279cf9fbb1c4/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c", size = 1980591 }, - { url = "https://files.pythonhosted.org/packages/f1/14/77c1887a182d05af74f6aeac7b740da3a74155d3093ccc7ee10b900cc6b5/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27", size = 1981326 }, - { url = "https://files.pythonhosted.org/packages/06/aa/6f1b2747f811a9c66b5ef39d7f02fbb200479784c75e98290d70004b1253/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f", size = 1989205 }, - { url = "https://files.pythonhosted.org/packages/7a/d2/8ce2b074d6835f3c88d85f6d8a399790043e9fdb3d0e43455e72d19df8cc/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed", size = 2079616 }, - { url = "https://files.pythonhosted.org/packages/65/71/af01033d4e58484c3db1e5d13e751ba5e3d6b87cc3368533df4c50932c8b/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f", size = 2133265 }, - { url = "https://files.pythonhosted.org/packages/33/72/f881b5e18fbb67cf2fb4ab253660de3c6899dbb2dba409d0b757e3559e3d/pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c", size = 2001864 }, +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, ] [[package]] name = "pydantic-settings" -version = "2.6.1" +version = "2.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/d4/9dfbe238f45ad8b168f5c96ee49a3df0598ce18a0795a983b419949ce65b/pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0", size = 75646 } +sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f9/ff95fd7d760af42f647ea87f9b8a383d891cdb5e5dbd4613edaeb094252a/pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87", size = 28595 }, + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, ] [[package]] name = "pygments" -version = "2.18.0" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, ] [[package]] name = "pymdown-extensions" -version = "10.14.3" +version = "10.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/44/e6de2fdc880ad0ec7547ca2e087212be815efbc9a425a8d5ba9ede602cbb/pymdown_extensions-10.14.3.tar.gz", hash = "sha256:41e576ce3f5d650be59e900e4ceff231e0aed2a88cf30acaee41e02f063a061b", size = 846846 } +sdist = { url = "https://files.pythonhosted.org/packages/55/b3/6d2b3f149bc5413b0a29761c2c5832d8ce904a1d7f621e86616d96f505cc/pymdown_extensions-10.16.1.tar.gz", hash = "sha256:aace82bcccba3efc03e25d584e6a22d27a8e17caa3f4dd9f207e49b787aa9a91", size = 853277, upload-time = "2025-07-28T16:19:34.167Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/f5/b9e2a42aa8f9e34d52d66de87941ecd236570c7ed2e87775ed23bbe4e224/pymdown_extensions-10.14.3-py3-none-any.whl", hash = "sha256:05e0bee73d64b9c71a4ae17c72abc2f700e8bc8403755a00580b49a4e9f189e9", size = 264467 }, + { url = "https://files.pythonhosted.org/packages/e4/06/43084e6cbd4b3bc0e80f6be743b2e79fbc6eed8de9ad8c629939fa55d972/pymdown_extensions-10.16.1-py3-none-any.whl", hash = "sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d", size = 266178, upload-time = "2025-07-28T16:19:31.401Z" }, ] [[package]] name = "pyright" -version = "1.1.391" +version = "1.1.405" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/05/4ea52a8a45cc28897edb485b4102d37cbfd5fce8445d679cdeb62bfad221/pyright-1.1.391.tar.gz", hash = "sha256:66b2d42cdf5c3cbab05f2f4b76e8bec8aa78e679bfa0b6ad7b923d9e027cadb2", size = 21965 } +sdist = { url = "https://files.pythonhosted.org/packages/fb/6c/ba4bbee22e76af700ea593a1d8701e3225080956753bee9750dcc25e2649/pyright-1.1.405.tar.gz", hash = "sha256:5c2a30e1037af27eb463a1cc0b9f6d65fec48478ccf092c1ac28385a15c55763", size = 4068319, upload-time = "2025-09-04T03:37:06.776Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/89/66f49552fbeb21944c8077d11834b2201514a56fd1b7747ffff9630f1bd9/pyright-1.1.391-py3-none-any.whl", hash = "sha256:54fa186f8b3e8a55a44ebfa842636635688670c6896dcf6cf4a7fc75062f4d15", size = 18579 }, + { url = "https://files.pythonhosted.org/packages/d5/1a/524f832e1ff1962a22a1accc775ca7b143ba2e9f5924bb6749dce566784a/pyright-1.1.405-py3-none-any.whl", hash = "sha256:a2cb13700b5508ce8e5d4546034cb7ea4aedb60215c6c33f56cec7f53996035a", size = 5905038, upload-time = "2025-09-04T03:37:04.913Z" }, ] [[package]] name = "pytest" -version = "8.3.4" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1098,25 +1956,26 @@ dependencies = [ { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, + { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] name = "pytest-examples" -version = "0.0.14" +version = "0.0.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "black" }, { name = "pytest" }, { name = "ruff" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d2/a7/b81d5cf26e9713a2d4c8e6863ee009360c5c07a0cfb880456ec8b09adab7/pytest_examples-0.0.14.tar.gz", hash = "sha256:776d1910709c0c5ce01b29bfe3651c5312d5cfe5c063e23ca6f65aed9af23f09", size = 20767 } +sdist = { url = "https://files.pythonhosted.org/packages/af/71/4ae972fd95f474454aa450108ee1037830e7ba11840363e981b8d48fd16a/pytest_examples-0.0.18.tar.gz", hash = "sha256:9a464f007f805b113677a15e2f8942ebb92d7d3eb5312e9a405d018478ec9801", size = 21237, upload-time = "2025-05-06T07:46:10.705Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/99/f418071551ff2b5e8c06bd8b82b1f4fd472b5e4162f018773ba4ef52b6e8/pytest_examples-0.0.14-py3-none-any.whl", hash = "sha256:867a7ea105635d395df712a4b8d0df3bda4c3d78ae97a57b4f115721952b5e25", size = 17919 }, + { url = "https://files.pythonhosted.org/packages/09/52/7bbfb6e987d9a8a945f22941a8da63e3529465f1b106ef0e26f5df7c780d/pytest_examples-0.0.18-py3-none-any.whl", hash = "sha256:86c195b98c4e55049a0df3a0a990ca89123b7280473ab57608eecc6c47bcfe9c", size = 18169, upload-time = "2025-05-06T07:46:09.349Z" }, ] [[package]] @@ -1126,22 +1985,35 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/53/69c56a93ea057895b5761c5318455804873a6cd9d796d7c55d41c2358125/pytest-flakefinder-1.1.0.tar.gz", hash = "sha256:e2412a1920bdb8e7908783b20b3d57e9dad590cc39a93e8596ffdd493b403e0e", size = 6795 } +sdist = { url = "https://files.pythonhosted.org/packages/ec/53/69c56a93ea057895b5761c5318455804873a6cd9d796d7c55d41c2358125/pytest-flakefinder-1.1.0.tar.gz", hash = "sha256:e2412a1920bdb8e7908783b20b3d57e9dad590cc39a93e8596ffdd493b403e0e", size = 6795, upload-time = "2022-10-26T18:27:54.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8b/06787150d0fd0cbd3a8054262b56f91631c7778c1bc91bf4637e47f909ad/pytest_flakefinder-1.1.0-py2.py3-none-any.whl", hash = "sha256:741e0e8eea427052f5b8c89c2b3c3019a50c39a59ce4df6a305a2c2d9ba2bd13", size = 4644, upload-time = "2022-10-26T18:27:52.128Z" }, +] + +[[package]] +name = "pytest-pretty" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/d7/c699e0be5401fe9ccad484562f0af9350b4e48c05acf39fb3dab1932128f/pytest_pretty-1.3.0.tar.gz", hash = "sha256:97e9921be40f003e40ae78db078d4a0c1ea42bf73418097b5077970c2cc43bf3", size = 219297, upload-time = "2025-06-04T12:54:37.322Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/8b/06787150d0fd0cbd3a8054262b56f91631c7778c1bc91bf4637e47f909ad/pytest_flakefinder-1.1.0-py2.py3-none-any.whl", hash = "sha256:741e0e8eea427052f5b8c89c2b3c3019a50c39a59ce4df6a305a2c2d9ba2bd13", size = 4644 }, + { url = "https://files.pythonhosted.org/packages/ab/85/2f97a1b65178b0f11c9c77c35417a4cc5b99a80db90dad4734a129844ea5/pytest_pretty-1.3.0-py3-none-any.whl", hash = "sha256:074b9d5783cef9571494543de07e768a4dda92a3e85118d6c7458c67297159b7", size = 5620, upload-time = "2025-06-04T12:54:36.229Z" }, ] [[package]] name = "pytest-xdist" -version = "3.6.1" +version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "execnet" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/c4/3c310a19bc1f1e9ef50075582652673ef2bfc8cd62afef9585683821902f/pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d", size = 84060 } +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7", size = 46108 }, + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] [[package]] @@ -1151,148 +2023,124 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "python-dotenv" -version = "1.0.0" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pywin32" +version = "311" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/06/1ef763af20d0572c032fa22882cfbfb005fba6e7300715a37840858c919e/python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba", size = 37399 } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/2f/62ea1c8b593f4e093cc1a7768f0d46112107e790c3e478532329e434f00b/python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a", size = 19482 }, + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] [[package]] name = "pyyaml" version = "6.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, - { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, - { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, - { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, - { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, - { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, - { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, - { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, - { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, - { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, - { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, - { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, - { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, - { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, - { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, - { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, - { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, - { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] [[package]] name = "pyyaml-env-tag" -version = "0.1" +version = "1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/8e/da1c6c58f751b70f8ceb1eb25bc25d524e8f14fe16edcce3f4e3ba08629c/pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", size = 5631 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/66/bbb1dd374f5c870f59c5bb1db0e18cbe7fa739415a24cbd95b2d1f5ae0c4/pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069", size = 3911 }, -] - -[[package]] -name = "regex" -version = "2024.11.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/3c/4651f6b130c6842a8f3df82461a8950f923925db8b6961063e82744bddcc/regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91", size = 482674 }, - { url = "https://files.pythonhosted.org/packages/15/51/9f35d12da8434b489c7b7bffc205c474a0a9432a889457026e9bc06a297a/regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0", size = 287684 }, - { url = "https://files.pythonhosted.org/packages/bd/18/b731f5510d1b8fb63c6b6d3484bfa9a59b84cc578ac8b5172970e05ae07c/regex-2024.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e", size = 284589 }, - { url = "https://files.pythonhosted.org/packages/78/a2/6dd36e16341ab95e4c6073426561b9bfdeb1a9c9b63ab1b579c2e96cb105/regex-2024.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde", size = 782511 }, - { url = "https://files.pythonhosted.org/packages/1b/2b/323e72d5d2fd8de0d9baa443e1ed70363ed7e7b2fb526f5950c5cb99c364/regex-2024.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e", size = 821149 }, - { url = "https://files.pythonhosted.org/packages/90/30/63373b9ea468fbef8a907fd273e5c329b8c9535fee36fc8dba5fecac475d/regex-2024.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2", size = 809707 }, - { url = "https://files.pythonhosted.org/packages/f2/98/26d3830875b53071f1f0ae6d547f1d98e964dd29ad35cbf94439120bb67a/regex-2024.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf", size = 781702 }, - { url = "https://files.pythonhosted.org/packages/87/55/eb2a068334274db86208ab9d5599ffa63631b9f0f67ed70ea7c82a69bbc8/regex-2024.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c", size = 771976 }, - { url = "https://files.pythonhosted.org/packages/74/c0/be707bcfe98254d8f9d2cff55d216e946f4ea48ad2fd8cf1428f8c5332ba/regex-2024.11.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86", size = 697397 }, - { url = "https://files.pythonhosted.org/packages/49/dc/bb45572ceb49e0f6509f7596e4ba7031f6819ecb26bc7610979af5a77f45/regex-2024.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67", size = 768726 }, - { url = "https://files.pythonhosted.org/packages/5a/db/f43fd75dc4c0c2d96d0881967897926942e935d700863666f3c844a72ce6/regex-2024.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d", size = 775098 }, - { url = "https://files.pythonhosted.org/packages/99/d7/f94154db29ab5a89d69ff893159b19ada89e76b915c1293e98603d39838c/regex-2024.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2", size = 839325 }, - { url = "https://files.pythonhosted.org/packages/f7/17/3cbfab1f23356fbbf07708220ab438a7efa1e0f34195bf857433f79f1788/regex-2024.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008", size = 843277 }, - { url = "https://files.pythonhosted.org/packages/7e/f2/48b393b51900456155de3ad001900f94298965e1cad1c772b87f9cfea011/regex-2024.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62", size = 773197 }, - { url = "https://files.pythonhosted.org/packages/45/3f/ef9589aba93e084cd3f8471fded352826dcae8489b650d0b9b27bc5bba8a/regex-2024.11.6-cp310-cp310-win32.whl", hash = "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e", size = 261714 }, - { url = "https://files.pythonhosted.org/packages/42/7e/5f1b92c8468290c465fd50c5318da64319133231415a8aa6ea5ab995a815/regex-2024.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519", size = 274042 }, - { url = "https://files.pythonhosted.org/packages/58/58/7e4d9493a66c88a7da6d205768119f51af0f684fe7be7bac8328e217a52c/regex-2024.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638", size = 482669 }, - { url = "https://files.pythonhosted.org/packages/34/4c/8f8e631fcdc2ff978609eaeef1d6994bf2f028b59d9ac67640ed051f1218/regex-2024.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7", size = 287684 }, - { url = "https://files.pythonhosted.org/packages/c5/1b/f0e4d13e6adf866ce9b069e191f303a30ab1277e037037a365c3aad5cc9c/regex-2024.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20", size = 284589 }, - { url = "https://files.pythonhosted.org/packages/25/4d/ab21047f446693887f25510887e6820b93f791992994f6498b0318904d4a/regex-2024.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114", size = 792121 }, - { url = "https://files.pythonhosted.org/packages/45/ee/c867e15cd894985cb32b731d89576c41a4642a57850c162490ea34b78c3b/regex-2024.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3", size = 831275 }, - { url = "https://files.pythonhosted.org/packages/b3/12/b0f480726cf1c60f6536fa5e1c95275a77624f3ac8fdccf79e6727499e28/regex-2024.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f", size = 818257 }, - { url = "https://files.pythonhosted.org/packages/bf/ce/0d0e61429f603bac433910d99ef1a02ce45a8967ffbe3cbee48599e62d88/regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0", size = 792727 }, - { url = "https://files.pythonhosted.org/packages/e4/c1/243c83c53d4a419c1556f43777ccb552bccdf79d08fda3980e4e77dd9137/regex-2024.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55", size = 780667 }, - { url = "https://files.pythonhosted.org/packages/c5/f4/75eb0dd4ce4b37f04928987f1d22547ddaf6c4bae697623c1b05da67a8aa/regex-2024.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89", size = 776963 }, - { url = "https://files.pythonhosted.org/packages/16/5d/95c568574e630e141a69ff8a254c2f188b4398e813c40d49228c9bbd9875/regex-2024.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d", size = 784700 }, - { url = "https://files.pythonhosted.org/packages/8e/b5/f8495c7917f15cc6fee1e7f395e324ec3e00ab3c665a7dc9d27562fd5290/regex-2024.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34", size = 848592 }, - { url = "https://files.pythonhosted.org/packages/1c/80/6dd7118e8cb212c3c60b191b932dc57db93fb2e36fb9e0e92f72a5909af9/regex-2024.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d", size = 852929 }, - { url = "https://files.pythonhosted.org/packages/11/9b/5a05d2040297d2d254baf95eeeb6df83554e5e1df03bc1a6687fc4ba1f66/regex-2024.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45", size = 781213 }, - { url = "https://files.pythonhosted.org/packages/26/b7/b14e2440156ab39e0177506c08c18accaf2b8932e39fb092074de733d868/regex-2024.11.6-cp311-cp311-win32.whl", hash = "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9", size = 261734 }, - { url = "https://files.pythonhosted.org/packages/80/32/763a6cc01d21fb3819227a1cc3f60fd251c13c37c27a73b8ff4315433a8e/regex-2024.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60", size = 274052 }, - { url = "https://files.pythonhosted.org/packages/ba/30/9a87ce8336b172cc232a0db89a3af97929d06c11ceaa19d97d84fa90a8f8/regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", size = 483781 }, - { url = "https://files.pythonhosted.org/packages/01/e8/00008ad4ff4be8b1844786ba6636035f7ef926db5686e4c0f98093612add/regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", size = 288455 }, - { url = "https://files.pythonhosted.org/packages/60/85/cebcc0aff603ea0a201667b203f13ba75d9fc8668fab917ac5b2de3967bc/regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", size = 284759 }, - { url = "https://files.pythonhosted.org/packages/94/2b/701a4b0585cb05472a4da28ee28fdfe155f3638f5e1ec92306d924e5faf0/regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", size = 794976 }, - { url = "https://files.pythonhosted.org/packages/4b/bf/fa87e563bf5fee75db8915f7352e1887b1249126a1be4813837f5dbec965/regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", size = 833077 }, - { url = "https://files.pythonhosted.org/packages/a1/56/7295e6bad94b047f4d0834e4779491b81216583c00c288252ef625c01d23/regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", size = 823160 }, - { url = "https://files.pythonhosted.org/packages/fb/13/e3b075031a738c9598c51cfbc4c7879e26729c53aa9cca59211c44235314/regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", size = 796896 }, - { url = "https://files.pythonhosted.org/packages/24/56/0b3f1b66d592be6efec23a795b37732682520b47c53da5a32c33ed7d84e3/regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", size = 783997 }, - { url = "https://files.pythonhosted.org/packages/f9/a1/eb378dada8b91c0e4c5f08ffb56f25fcae47bf52ad18f9b2f33b83e6d498/regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", size = 781725 }, - { url = "https://files.pythonhosted.org/packages/83/f2/033e7dec0cfd6dda93390089864732a3409246ffe8b042e9554afa9bff4e/regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", size = 789481 }, - { url = "https://files.pythonhosted.org/packages/83/23/15d4552ea28990a74e7696780c438aadd73a20318c47e527b47a4a5a596d/regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", size = 852896 }, - { url = "https://files.pythonhosted.org/packages/e3/39/ed4416bc90deedbfdada2568b2cb0bc1fdb98efe11f5378d9892b2a88f8f/regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", size = 860138 }, - { url = "https://files.pythonhosted.org/packages/93/2d/dd56bb76bd8e95bbce684326302f287455b56242a4f9c61f1bc76e28360e/regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", size = 787692 }, - { url = "https://files.pythonhosted.org/packages/0b/55/31877a249ab7a5156758246b9c59539abbeba22461b7d8adc9e8475ff73e/regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", size = 262135 }, - { url = "https://files.pythonhosted.org/packages/38/ec/ad2d7de49a600cdb8dd78434a1aeffe28b9d6fc42eb36afab4a27ad23384/regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", size = 273567 }, - { url = "https://files.pythonhosted.org/packages/90/73/bcb0e36614601016552fa9344544a3a2ae1809dc1401b100eab02e772e1f/regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", size = 483525 }, - { url = "https://files.pythonhosted.org/packages/0f/3f/f1a082a46b31e25291d830b369b6b0c5576a6f7fb89d3053a354c24b8a83/regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", size = 288324 }, - { url = "https://files.pythonhosted.org/packages/09/c9/4e68181a4a652fb3ef5099e077faf4fd2a694ea6e0f806a7737aff9e758a/regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", size = 284617 }, - { url = "https://files.pythonhosted.org/packages/fc/fd/37868b75eaf63843165f1d2122ca6cb94bfc0271e4428cf58c0616786dce/regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0", size = 795023 }, - { url = "https://files.pythonhosted.org/packages/c4/7c/d4cd9c528502a3dedb5c13c146e7a7a539a3853dc20209c8e75d9ba9d1b2/regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7", size = 833072 }, - { url = "https://files.pythonhosted.org/packages/4f/db/46f563a08f969159c5a0f0e722260568425363bea43bb7ae370becb66a67/regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7", size = 823130 }, - { url = "https://files.pythonhosted.org/packages/db/60/1eeca2074f5b87df394fccaa432ae3fc06c9c9bfa97c5051aed70e6e00c2/regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c", size = 796857 }, - { url = "https://files.pythonhosted.org/packages/10/db/ac718a08fcee981554d2f7bb8402f1faa7e868c1345c16ab1ebec54b0d7b/regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3", size = 784006 }, - { url = "https://files.pythonhosted.org/packages/c2/41/7da3fe70216cea93144bf12da2b87367590bcf07db97604edeea55dac9ad/regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07", size = 781650 }, - { url = "https://files.pythonhosted.org/packages/a7/d5/880921ee4eec393a4752e6ab9f0fe28009435417c3102fc413f3fe81c4e5/regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e", size = 789545 }, - { url = "https://files.pythonhosted.org/packages/dc/96/53770115e507081122beca8899ab7f5ae28ae790bfcc82b5e38976df6a77/regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6", size = 853045 }, - { url = "https://files.pythonhosted.org/packages/31/d3/1372add5251cc2d44b451bd94f43b2ec78e15a6e82bff6a290ef9fd8f00a/regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4", size = 860182 }, - { url = "https://files.pythonhosted.org/packages/ed/e3/c446a64984ea9f69982ba1a69d4658d5014bc7a0ea468a07e1a1265db6e2/regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", size = 787733 }, - { url = "https://files.pythonhosted.org/packages/2b/f1/e40c8373e3480e4f29f2692bd21b3e05f296d3afebc7e5dcf21b9756ca1c/regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", size = 262122 }, - { url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545 }, +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, ] [[package]] name = "requests" -version = "2.32.3" +version = "2.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1300,108 +2148,292 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] name = "rich" -version = "13.9.4" +version = "14.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/ed/3aef893e2dd30e77e35d20d4ddb45ca459db59cead748cad9796ad479411/rpds_py-0.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:68afeec26d42ab3b47e541b272166a0b4400313946871cba3ed3a4fc0cab1cef", size = 371606, upload-time = "2025-08-27T12:12:25.189Z" }, + { url = "https://files.pythonhosted.org/packages/6d/82/9818b443e5d3eb4c83c3994561387f116aae9833b35c484474769c4a8faf/rpds_py-0.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74e5b2f7bb6fa38b1b10546d27acbacf2a022a8b5543efb06cfebc72a59c85be", size = 353452, upload-time = "2025-08-27T12:12:27.433Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/d2a110ffaaa397fc6793a83c7bd3545d9ab22658b7cdff05a24a4535cc45/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9024de74731df54546fab0bfbcdb49fae19159ecaecfc8f37c18d2c7e2c0bd61", size = 381519, upload-time = "2025-08-27T12:12:28.719Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bc/e89581d1f9d1be7d0247eaef602566869fdc0d084008ba139e27e775366c/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d3ebadefcd73b73928ed0b2fd696f7fefda8629229f81929ac9c1854d0cffb", size = 394424, upload-time = "2025-08-27T12:12:30.207Z" }, + { url = "https://files.pythonhosted.org/packages/ac/2e/36a6861f797530e74bb6ed53495f8741f1ef95939eed01d761e73d559067/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2e7f8f169d775dd9092a1743768d771f1d1300453ddfe6325ae3ab5332b4657", size = 523467, upload-time = "2025-08-27T12:12:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/c1bc2be32564fa499f988f0a5c6505c2f4746ef96e58e4d7de5cf923d77e/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d905d16f77eb6ab2e324e09bfa277b4c8e5e6b8a78a3e7ff8f3cdf773b4c013", size = 402660, upload-time = "2025-08-27T12:12:33.444Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ec/ef8bf895f0628dd0a59e54d81caed6891663cb9c54a0f4bb7da918cb88cf/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50c946f048209e6362e22576baea09193809f87687a95a8db24e5fbdb307b93a", size = 384062, upload-time = "2025-08-27T12:12:34.857Z" }, + { url = "https://files.pythonhosted.org/packages/69/f7/f47ff154be8d9a5e691c083a920bba89cef88d5247c241c10b9898f595a1/rpds_py-0.27.1-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:3deab27804d65cd8289eb814c2c0e807c4b9d9916c9225e363cb0cf875eb67c1", size = 401289, upload-time = "2025-08-27T12:12:36.085Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d9/ca410363efd0615814ae579f6829cafb39225cd63e5ea5ed1404cb345293/rpds_py-0.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b61097f7488de4be8244c89915da8ed212832ccf1e7c7753a25a394bf9b1f10", size = 417718, upload-time = "2025-08-27T12:12:37.401Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a0/8cb5c2ff38340f221cc067cc093d1270e10658ba4e8d263df923daa18e86/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a3f29aba6e2d7d90528d3c792555a93497fe6538aa65eb675b44505be747808", size = 558333, upload-time = "2025-08-27T12:12:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8c/1b0de79177c5d5103843774ce12b84caa7164dfc6cd66378768d37db11bf/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd6cd0485b7d347304067153a6dc1d73f7d4fd995a396ef32a24d24b8ac63ac8", size = 589127, upload-time = "2025-08-27T12:12:41.48Z" }, + { url = "https://files.pythonhosted.org/packages/c8/5e/26abb098d5e01266b0f3a2488d299d19ccc26849735d9d2b95c39397e945/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f4461bf931108c9fa226ffb0e257c1b18dc2d44cd72b125bec50ee0ab1248a9", size = 554899, upload-time = "2025-08-27T12:12:42.925Z" }, + { url = "https://files.pythonhosted.org/packages/de/41/905cc90ced13550db017f8f20c6d8e8470066c5738ba480d7ba63e3d136b/rpds_py-0.27.1-cp310-cp310-win32.whl", hash = "sha256:ee5422d7fb21f6a00c1901bf6559c49fee13a5159d0288320737bbf6585bd3e4", size = 217450, upload-time = "2025-08-27T12:12:44.813Z" }, + { url = "https://files.pythonhosted.org/packages/75/3d/6bef47b0e253616ccdf67c283e25f2d16e18ccddd38f92af81d5a3420206/rpds_py-0.27.1-cp310-cp310-win_amd64.whl", hash = "sha256:3e039aabf6d5f83c745d5f9a0a381d031e9ed871967c0a5c38d201aca41f3ba1", size = 228447, upload-time = "2025-08-27T12:12:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c1/7907329fbef97cbd49db6f7303893bd1dd5a4a3eae415839ffdfb0762cae/rpds_py-0.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:be898f271f851f68b318872ce6ebebbc62f303b654e43bf72683dbdc25b7c881", size = 371063, upload-time = "2025-08-27T12:12:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/2aab4bc86228bcf7c48760990273653a4900de89c7537ffe1b0d6097ed39/rpds_py-0.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62ac3d4e3e07b58ee0ddecd71d6ce3b1637de2d373501412df395a0ec5f9beb5", size = 353210, upload-time = "2025-08-27T12:12:49.187Z" }, + { url = "https://files.pythonhosted.org/packages/3a/57/f5eb3ecf434342f4f1a46009530e93fd201a0b5b83379034ebdb1d7c1a58/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4708c5c0ceb2d034f9991623631d3d23cb16e65c83736ea020cdbe28d57c0a0e", size = 381636, upload-time = "2025-08-27T12:12:50.492Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f4/ef95c5945e2ceb5119571b184dd5a1cc4b8541bbdf67461998cfeac9cb1e/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:abfa1171a9952d2e0002aba2ad3780820b00cc3d9c98c6630f2e93271501f66c", size = 394341, upload-time = "2025-08-27T12:12:52.024Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7e/4bd610754bf492d398b61725eb9598ddd5eb86b07d7d9483dbcd810e20bc/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b507d19f817ebaca79574b16eb2ae412e5c0835542c93fe9983f1e432aca195", size = 523428, upload-time = "2025-08-27T12:12:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e5/059b9f65a8c9149361a8b75094864ab83b94718344db511fd6117936ed2a/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168b025f8fd8d8d10957405f3fdcef3dc20f5982d398f90851f4abc58c566c52", size = 402923, upload-time = "2025-08-27T12:12:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/64cabb7daced2968dd08e8a1b7988bf358d7bd5bcd5dc89a652f4668543c/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb56c6210ef77caa58e16e8c17d35c63fe3f5b60fd9ba9d424470c3400bcf9ed", size = 384094, upload-time = "2025-08-27T12:12:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e1/dc9094d6ff566bff87add8a510c89b9e158ad2ecd97ee26e677da29a9e1b/rpds_py-0.27.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:d252f2d8ca0195faa707f8eb9368955760880b2b42a8ee16d382bf5dd807f89a", size = 401093, upload-time = "2025-08-27T12:12:58.985Z" }, + { url = "https://files.pythonhosted.org/packages/37/8e/ac8577e3ecdd5593e283d46907d7011618994e1d7ab992711ae0f78b9937/rpds_py-0.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6e5e54da1e74b91dbc7996b56640f79b195d5925c2b78efaa8c5d53e1d88edde", size = 417969, upload-time = "2025-08-27T12:13:00.367Z" }, + { url = "https://files.pythonhosted.org/packages/66/6d/87507430a8f74a93556fe55c6485ba9c259949a853ce407b1e23fea5ba31/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ffce0481cc6e95e5b3f0a47ee17ffbd234399e6d532f394c8dce320c3b089c21", size = 558302, upload-time = "2025-08-27T12:13:01.737Z" }, + { url = "https://files.pythonhosted.org/packages/3a/bb/1db4781ce1dda3eecc735e3152659a27b90a02ca62bfeea17aee45cc0fbc/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a205fdfe55c90c2cd8e540ca9ceba65cbe6629b443bc05db1f590a3db8189ff9", size = 589259, upload-time = "2025-08-27T12:13:03.127Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/ae1c8943d11a814d01b482e1f8da903f88047a962dff9bbdadf3bd6e6fd1/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:689fb5200a749db0415b092972e8eba85847c23885c8543a8b0f5c009b1a5948", size = 554983, upload-time = "2025-08-27T12:13:04.516Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/0b2a55415931db4f112bdab072443ff76131b5ac4f4dc98d10d2d357eb03/rpds_py-0.27.1-cp311-cp311-win32.whl", hash = "sha256:3182af66048c00a075010bc7f4860f33913528a4b6fc09094a6e7598e462fe39", size = 217154, upload-time = "2025-08-27T12:13:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/3b7ffe0d50dc86a6a964af0d1cc3a4a2cdf437cb7b099a4747bbb96d1819/rpds_py-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:b4938466c6b257b2f5c4ff98acd8128ec36b5059e5c8f8372d79316b1c36bb15", size = 228627, upload-time = "2025-08-27T12:13:07.625Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3f/4fd04c32abc02c710f09a72a30c9a55ea3cc154ef8099078fd50a0596f8e/rpds_py-0.27.1-cp311-cp311-win_arm64.whl", hash = "sha256:2f57af9b4d0793e53266ee4325535a31ba48e2f875da81a9177c9926dfa60746", size = 220998, upload-time = "2025-08-27T12:13:08.972Z" }, + { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" }, + { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" }, + { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" }, + { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" }, + { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" }, + { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" }, + { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" }, + { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" }, + { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" }, + { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" }, + { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" }, + { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" }, + { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" }, + { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" }, + { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" }, + { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" }, + { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" }, + { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" }, + { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" }, + { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" }, + { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" }, + { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" }, + { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" }, + { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" }, + { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" }, + { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/d5/63/b7cc415c345625d5e62f694ea356c58fb964861409008118f1245f8c3347/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7ba22cb9693df986033b91ae1d7a979bc399237d45fccf875b76f62bb9e52ddf", size = 371360, upload-time = "2025-08-27T12:15:29.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/12e1b24b560cf378b8ffbdb9dc73abd529e1adcfcf82727dfd29c4a7b88d/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b640501be9288c77738b5492b3fd3abc4ba95c50c2e41273c8a1459f08298d3", size = 353933, upload-time = "2025-08-27T12:15:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/9b/85/1bb2210c1f7a1b99e91fea486b9f0f894aa5da3a5ec7097cbad7dec6d40f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb08b65b93e0c6dd70aac7f7890a9c0938d5ec71d5cb32d45cf844fb8ae47636", size = 382962, upload-time = "2025-08-27T12:15:32.348Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/a839b9f219cf80ed65f27a7f5ddbb2809c1b85c966020ae2dff490e0b18e/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7ff07d696a7a38152ebdb8212ca9e5baab56656749f3d6004b34ab726b550b8", size = 394412, upload-time = "2025-08-27T12:15:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/02/2d/b1d7f928b0b1f4fc2e0133e8051d199b01d7384875adc63b6ddadf3de7e5/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb7c72262deae25366e3b6c0c0ba46007967aea15d1eea746e44ddba8ec58dcc", size = 523972, upload-time = "2025-08-27T12:15:35.377Z" }, + { url = "https://files.pythonhosted.org/packages/a9/af/2cbf56edd2d07716df1aec8a726b3159deb47cb5c27e1e42b71d705a7c2f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b002cab05d6339716b03a4a3a2ce26737f6231d7b523f339fa061d53368c9d8", size = 403273, upload-time = "2025-08-27T12:15:37.051Z" }, + { url = "https://files.pythonhosted.org/packages/c0/93/425e32200158d44ff01da5d9612c3b6711fe69f606f06e3895511f17473b/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23f6b69d1c26c4704fec01311963a41d7de3ee0570a84ebde4d544e5a1859ffc", size = 385278, upload-time = "2025-08-27T12:15:38.571Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1a/1a04a915ecd0551bfa9e77b7672d1937b4b72a0fc204a17deef76001cfb2/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:530064db9146b247351f2a0250b8f00b289accea4596a033e94be2389977de71", size = 402084, upload-time = "2025-08-27T12:15:40.529Z" }, + { url = "https://files.pythonhosted.org/packages/51/f7/66585c0fe5714368b62951d2513b684e5215beaceab2c6629549ddb15036/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b90b0496570bd6b0321724a330d8b545827c4df2034b6ddfc5f5275f55da2ad", size = 419041, upload-time = "2025-08-27T12:15:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7e/83a508f6b8e219bba2d4af077c35ba0e0cdd35a751a3be6a7cba5a55ad71/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:879b0e14a2da6a1102a3fc8af580fc1ead37e6d6692a781bd8c83da37429b5ab", size = 560084, upload-time = "2025-08-27T12:15:43.839Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/bb945683b958a1b19eb0fe715594630d0f36396ebdef4d9b89c2fa09aa56/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:0d807710df3b5faa66c731afa162ea29717ab3be17bdc15f90f2d9f183da4059", size = 590115, upload-time = "2025-08-27T12:15:46.647Z" }, + { url = "https://files.pythonhosted.org/packages/12/00/ccfaafaf7db7e7adace915e5c2f2c2410e16402561801e9c7f96683002d3/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3adc388fc3afb6540aec081fa59e6e0d3908722771aa1e37ffe22b220a436f0b", size = 556561, upload-time = "2025-08-27T12:15:48.219Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b7/92b6ed9aad103bfe1c45df98453dfae40969eef2cb6c6239c58d7e96f1b3/rpds_py-0.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c796c0c1cc68cb08b0284db4229f5af76168172670c74908fdbd4b7d7f515819", size = 229125, upload-time = "2025-08-27T12:15:49.956Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ed/e1fba02de17f4f76318b834425257c8ea297e415e12c68b4361f63e8ae92/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdfe4bb2f9fe7458b7453ad3c33e726d6d1c7c0a72960bcc23800d77384e42df", size = 371402, upload-time = "2025-08-27T12:15:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/af/7c/e16b959b316048b55585a697e94add55a4ae0d984434d279ea83442e460d/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8fabb8fd848a5f75a2324e4a84501ee3a5e3c78d8603f83475441866e60b94a3", size = 354084, upload-time = "2025-08-27T12:15:53.219Z" }, + { url = "https://files.pythonhosted.org/packages/de/c1/ade645f55de76799fdd08682d51ae6724cb46f318573f18be49b1e040428/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda8719d598f2f7f3e0f885cba8646644b55a187762bec091fa14a2b819746a9", size = 383090, upload-time = "2025-08-27T12:15:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/1f/27/89070ca9b856e52960da1472efcb6c20ba27cfe902f4f23ed095b9cfc61d/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c64d07e95606ec402a0a1c511fe003873fa6af630bda59bac77fac8b4318ebc", size = 394519, upload-time = "2025-08-27T12:15:57.238Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/be120586874ef906aa5aeeae95ae8df4184bc757e5b6bd1c729ccff45ed5/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93a2ed40de81bcff59aabebb626562d48332f3d028ca2036f1d23cbb52750be4", size = 523817, upload-time = "2025-08-27T12:15:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/70cc197bc11cfcde02a86f36ac1eed15c56667c2ebddbdb76a47e90306da/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:387ce8c44ae94e0ec50532d9cb0edce17311024c9794eb196b90e1058aadeb66", size = 403240, upload-time = "2025-08-27T12:16:00.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/46936cca449f7f518f2f4996e0e8344db4b57e2081e752441154089d2a5f/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf94f812c95b5e60ebaf8bfb1898a7d7cb9c1af5744d4a67fa47796e0465d4e", size = 385194, upload-time = "2025-08-27T12:16:02.802Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/29c0d3e5125c3270b51415af7cbff1ec587379c84f55a5761cc9efa8cd06/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4848ca84d6ded9b58e474dfdbad4b8bfb450344c0551ddc8d958bf4b36aa837c", size = 402086, upload-time = "2025-08-27T12:16:04.806Z" }, + { url = "https://files.pythonhosted.org/packages/8f/66/03e1087679227785474466fdd04157fb793b3b76e3fcf01cbf4c693c1949/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bde09cbcf2248b73c7c323be49b280180ff39fadcfe04e7b6f54a678d02a7cf", size = 419272, upload-time = "2025-08-27T12:16:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/6a/24/e3e72d265121e00b063aef3e3501e5b2473cf1b23511d56e529531acf01e/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:94c44ee01fd21c9058f124d2d4f0c9dc7634bec93cd4b38eefc385dabe71acbf", size = 560003, upload-time = "2025-08-27T12:16:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/f5a344c534214cc2d41118c0699fffbdc2c1bc7046f2a2b9609765ab9c92/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:df8b74962e35c9249425d90144e721eed198e6555a0e22a563d29fe4486b51f6", size = 590482, upload-time = "2025-08-27T12:16:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/ce/08/4349bdd5c64d9d193c360aa9db89adeee6f6682ab8825dca0a3f535f434f/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a", size = 556523, upload-time = "2025-08-27T12:16:12.188Z" }, ] [[package]] name = "ruff" -version = "0.8.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/5d/4b5403f3e89837decfd54c51bea7f94b7d3fae77e08858603d0e04d7ad17/ruff-0.8.5.tar.gz", hash = "sha256:1098d36f69831f7ff2a1da3e6407d5fbd6dfa2559e4f74ff2d260c5588900317", size = 3454835 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/f8/03391745a703ce11678eb37c48ae89ec60396ea821e9d0bcea7c8e88fd91/ruff-0.8.5-py3-none-linux_armv6l.whl", hash = "sha256:5ad11a5e3868a73ca1fa4727fe7e33735ea78b416313f4368c504dbeb69c0f88", size = 10626889 }, - { url = "https://files.pythonhosted.org/packages/55/74/83bb74a44183b904216f3edfb9995b89830c83aaa6ce84627f74da0e0cf8/ruff-0.8.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f69ab37771ea7e0715fead8624ec42996d101269a96e31f4d31be6fc33aa19b7", size = 10398233 }, - { url = "https://files.pythonhosted.org/packages/e8/7a/a162a4feb3ef85d594527165e366dde09d7a1e534186ff4ba5d127eda850/ruff-0.8.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b5462d7804558ccff9c08fe8cbf6c14b7efe67404316696a2dde48297b1925bb", size = 10001843 }, - { url = "https://files.pythonhosted.org/packages/e7/9f/5ee5dcd135411402e35b6ec6a8dfdadbd31c5cd1c36a624d356a38d76090/ruff-0.8.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d56de7220a35607f9fe59f8a6d018e14504f7b71d784d980835e20fc0611cd50", size = 10872507 }, - { url = "https://files.pythonhosted.org/packages/b6/67/db2df2dd4a34b602d7f6ebb1b3744c8157f0d3579973ffc58309c9c272e8/ruff-0.8.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9d99cf80b0429cbebf31cbbf6f24f05a29706f0437c40413d950e67e2d4faca4", size = 10377200 }, - { url = "https://files.pythonhosted.org/packages/fe/ff/fe3a6a73006bced73e60d171d154a82430f61d97e787f511a24bd6302611/ruff-0.8.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b75ac29715ac60d554a049dbb0ef3b55259076181c3369d79466cb130eb5afd", size = 11433155 }, - { url = "https://files.pythonhosted.org/packages/e3/95/c1d1a1fe36658c1f3e1b47e1cd5f688b72d5786695b9e621c2c38399a95e/ruff-0.8.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c9d526a62c9eda211b38463528768fd0ada25dad524cb33c0e99fcff1c67b5dc", size = 12139227 }, - { url = "https://files.pythonhosted.org/packages/1b/fe/644b70d473a27b5112ac7a3428edcc1ce0db775c301ff11aa146f71886e0/ruff-0.8.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:587c5e95007612c26509f30acc506c874dab4c4abbacd0357400bd1aa799931b", size = 11697941 }, - { url = "https://files.pythonhosted.org/packages/00/39/4f83e517ec173e16a47c6d102cd22a1aaebe80e1208a1f2e83ab9a0e4134/ruff-0.8.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:622b82bf3429ff0e346835ec213aec0a04d9730480cbffbb6ad9372014e31bbd", size = 12967686 }, - { url = "https://files.pythonhosted.org/packages/1a/f6/52a2973ff108d74b5da706a573379eea160bece098f7cfa3f35dc4622710/ruff-0.8.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f99be814d77a5dac8a8957104bdd8c359e85c86b0ee0e38dca447cb1095f70fb", size = 11253788 }, - { url = "https://files.pythonhosted.org/packages/ce/1f/3b30f3c65b1303cb8e268ec3b046b77ab21ed8e26921cfc7e8232aa57f2c/ruff-0.8.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01c048f9c3385e0fd7822ad0fd519afb282af9cf1778f3580e540629df89725", size = 10860360 }, - { url = "https://files.pythonhosted.org/packages/a5/a8/2a3ea6bacead963f7aeeba0c61815d9b27b0d638e6a74984aa5cc5d27733/ruff-0.8.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7512e8cb038db7f5db6aae0e24735ff9ea03bb0ed6ae2ce534e9baa23c1dc9ea", size = 10457922 }, - { url = "https://files.pythonhosted.org/packages/17/47/8f9514b670969aab57c5fc826fb500a16aee8feac1bcf8a91358f153a5ba/ruff-0.8.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:762f113232acd5b768d6b875d16aad6b00082add40ec91c927f0673a8ec4ede8", size = 10958347 }, - { url = "https://files.pythonhosted.org/packages/0d/d6/78a9af8209ad99541816d74f01ce678fc01ebb3f37dd7ab8966646dcd92b/ruff-0.8.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:03a90200c5dfff49e4c967b405f27fdfa81594cbb7c5ff5609e42d7fe9680da5", size = 11328882 }, - { url = "https://files.pythonhosted.org/packages/54/77/5c8072ec7afdfdf42c7a4019044486a2b6c85ee73617f8875ec94b977fed/ruff-0.8.5-py3-none-win32.whl", hash = "sha256:8710ffd57bdaa6690cbf6ecff19884b8629ec2a2a2a2f783aa94b1cc795139ed", size = 8802515 }, - { url = "https://files.pythonhosted.org/packages/bc/b6/47d2b06784de8ae992c45cceb2a30f3f205b3236a629d7ca4c0c134839a2/ruff-0.8.5-py3-none-win_amd64.whl", hash = "sha256:4020d8bf8d3a32325c77af452a9976a9ad6455773bcb94991cf15bd66b347e47", size = 9684231 }, - { url = "https://files.pythonhosted.org/packages/bf/5e/ffee22bf9f9e4b2669d1f0179ae8804584939fb6502b51f2401e26b1e028/ruff-0.8.5-py3-none-win_arm64.whl", hash = "sha256:134ae019ef13e1b060ab7136e7828a6d83ea727ba123381307eb37c6bd5e01cb", size = 9124741 }, +version = "0.12.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/f0/e0965dd709b8cabe6356811c0ee8c096806bb57d20b5019eb4e48a117410/ruff-0.12.12.tar.gz", hash = "sha256:b86cd3415dbe31b3b46a71c598f4c4b2f550346d1ccf6326b347cc0c8fd063d6", size = 5359915, upload-time = "2025-09-04T16:50:18.273Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/79/8d3d687224d88367b51c7974cec1040c4b015772bfbeffac95face14c04a/ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc", size = 12116602, upload-time = "2025-09-04T16:49:18.892Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c3/6e599657fe192462f94861a09aae935b869aea8a1da07f47d6eae471397c/ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727", size = 12868393, upload-time = "2025-09-04T16:49:23.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d2/9e3e40d399abc95336b1843f52fc0daaceb672d0e3c9290a28ff1a96f79d/ruff-0.12.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:abf4073688d7d6da16611f2f126be86523a8ec4343d15d276c614bda8ec44edb", size = 12036967, upload-time = "2025-09-04T16:49:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/e9/03/6816b2ed08836be272e87107d905f0908be5b4a40c14bfc91043e76631b8/ruff-0.12.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:968e77094b1d7a576992ac078557d1439df678a34c6fe02fd979f973af167577", size = 12276038, upload-time = "2025-09-04T16:49:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d5/707b92a61310edf358a389477eabd8af68f375c0ef858194be97ca5b6069/ruff-0.12.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42a67d16e5b1ffc6d21c5f67851e0e769517fb57a8ebad1d0781b30888aa704e", size = 11901110, upload-time = "2025-09-04T16:49:32.07Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3d/f8b1038f4b9822e26ec3d5b49cf2bc313e3c1564cceb4c1a42820bf74853/ruff-0.12.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b216ec0a0674e4b1214dcc998a5088e54eaf39417327b19ffefba1c4a1e4971e", size = 13668352, upload-time = "2025-09-04T16:49:35.148Z" }, + { url = "https://files.pythonhosted.org/packages/98/0e/91421368ae6c4f3765dd41a150f760c5f725516028a6be30e58255e3c668/ruff-0.12.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:59f909c0fdd8f1dcdbfed0b9569b8bf428cf144bec87d9de298dcd4723f5bee8", size = 14638365, upload-time = "2025-09-04T16:49:38.892Z" }, + { url = "https://files.pythonhosted.org/packages/74/5d/88f3f06a142f58ecc8ecb0c2fe0b82343e2a2b04dcd098809f717cf74b6c/ruff-0.12.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ac93d87047e765336f0c18eacad51dad0c1c33c9df7484c40f98e1d773876f5", size = 14060812, upload-time = "2025-09-04T16:49:42.732Z" }, + { url = "https://files.pythonhosted.org/packages/13/fc/8962e7ddd2e81863d5c92400820f650b86f97ff919c59836fbc4c1a6d84c/ruff-0.12.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01543c137fd3650d322922e8b14cc133b8ea734617c4891c5a9fccf4bfc9aa92", size = 13050208, upload-time = "2025-09-04T16:49:46.434Z" }, + { url = "https://files.pythonhosted.org/packages/53/06/8deb52d48a9a624fd37390555d9589e719eac568c020b27e96eed671f25f/ruff-0.12.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afc2fa864197634e549d87fb1e7b6feb01df0a80fd510d6489e1ce8c0b1cc45", size = 13311444, upload-time = "2025-09-04T16:49:49.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/de5a29af7eb8f341f8140867ffb93f82e4fde7256dadee79016ac87c2716/ruff-0.12.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0c0945246f5ad776cb8925e36af2438e66188d2b57d9cf2eed2c382c58b371e5", size = 13279474, upload-time = "2025-09-04T16:49:53.465Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/d9577fdeaf791737ada1b4f5c6b59c21c3326f3f683229096cccd7674e0c/ruff-0.12.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0fbafe8c58e37aae28b84a80ba1817f2ea552e9450156018a478bf1fa80f4e4", size = 12070204, upload-time = "2025-09-04T16:49:56.882Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/a910078284b47fad54506dc0af13839c418ff704e341c176f64e1127e461/ruff-0.12.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b9c456fb2fc8e1282affa932c9e40f5ec31ec9cbb66751a316bd131273b57c23", size = 11880347, upload-time = "2025-09-04T16:49:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/30185fcb0e89f05e7ea82e5817b47798f7fa7179863f9d9ba6fd4fe1b098/ruff-0.12.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f12856123b0ad0147d90b3961f5c90e7427f9acd4b40050705499c98983f489", size = 12891844, upload-time = "2025-09-04T16:50:02.591Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/28a8dacce4855e6703dcb8cdf6c1705d0b23dd01d60150786cd55aa93b16/ruff-0.12.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a1b5a2bf7dd2c47e3b46d077cd9c0fc3b93e6c6cc9ed750bd312ae9dc302ee", size = 13360687, upload-time = "2025-09-04T16:50:05.8Z" }, + { url = "https://files.pythonhosted.org/packages/c8/fa/05b6428a008e60f79546c943e54068316f32ec8ab5c4f73e4563934fbdc7/ruff-0.12.12-py3-none-win32.whl", hash = "sha256:173be2bfc142af07a01e3a759aba6f7791aa47acf3604f610b1c36db888df7b1", size = 12052870, upload-time = "2025-09-04T16:50:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/85/60/d1e335417804df452589271818749d061b22772b87efda88354cf35cdb7a/ruff-0.12.12-py3-none-win_amd64.whl", hash = "sha256:e99620bf01884e5f38611934c09dd194eb665b0109104acae3ba6102b600fd0d", size = 13178016, upload-time = "2025-09-04T16:50:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" }, +] + +[[package]] +name = "selectolax" +version = "0.3.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/b9/b5a23e29d5e54c590eaad18bdbb1ced13b869b111e03d12ee0ae9eecf9b8/selectolax-0.3.29.tar.gz", hash = "sha256:28696fa4581765c705e15d05dfba464334f5f9bcb3eac9f25045f815aec6fbc1", size = 4691626, upload-time = "2025-04-30T15:17:37.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/8f/bf3d58ecc0e187806299324e2ad77646e837ff20400880f6fc0cbd14fb66/selectolax-0.3.29-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:85aeae54f055cf5451828a21fbfecac99b8b5c27ec29fd10725b631593a7c9a3", size = 3643657, upload-time = "2025-04-30T15:15:40.734Z" }, + { url = "https://files.pythonhosted.org/packages/de/b0/6d90a4d0eacb8253d88a9fcbcb8758b667900f45dcdb4a11c5fbd0d31599/selectolax-0.3.29-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6ff48efe4364c8148a553a4105773a0accee9cc25e0f2a40ddac44d18a5a3000", size = 2089380, upload-time = "2025-04-30T15:15:42.928Z" }, + { url = "https://files.pythonhosted.org/packages/f4/21/394b51998ef99f13f98da063fc71b8edf7191bb30aca06bcbc8a55d5a9ad/selectolax-0.3.29-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25cfccfefc41361ab8a07f15a224524a4a8b77dfa7d253b34bbd397e45856734", size = 5505065, upload-time = "2025-04-30T15:15:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/dd/57/e38775b672f910e80742cbf7c3def5c670c1b6f9b05e8587b2fa8dc044c3/selectolax-0.3.29-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f5c3523ad5199a4fb9b95b6e24ff9222d3605023ca394b23f7dd910e7536daf", size = 5529205, upload-time = "2025-04-30T15:15:47.149Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/f6e3030107b486b6a4870f8471a675d435c4c34b8f9de3374652ed53004b/selectolax-0.3.29-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfb803d6bbe0ef3c8847cf5a01167cc428c0d9179946e1c994cc6178b5332d1a", size = 5146713, upload-time = "2025-04-30T15:15:49.332Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8d/b4fd119c216e8615ca6747f8f336643572178241921f33f5ffa4b074dc44/selectolax-0.3.29-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:db734ba4ef44fa3b57ad9374fd7ccfc7815c0ae5cfcbd5ee25fe8587092618d1", size = 5416352, upload-time = "2025-04-30T15:15:50.909Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e7/94e694d14ae44bddc0d9b144647d5adbec0210d8e2c57d72ad9a133d9469/selectolax-0.3.29-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2bfe4327215a20af4197c5b7e3729a9552fb324bb57250dc7e7abfa0f848a463", size = 5140689, upload-time = "2025-04-30T15:15:52.477Z" }, + { url = "https://files.pythonhosted.org/packages/90/62/79ba965daa1f12e5477b2ec08b289f8289dfc705928b08923d9c4b60c867/selectolax-0.3.29-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0a98c3f3d8fffb175456cb06096bc78103ddf6a209bea6392e0e4ea4e25aca71", size = 5481428, upload-time = "2025-04-30T15:15:54.371Z" }, + { url = "https://files.pythonhosted.org/packages/82/3c/46c1f0b739add89d0ef720ad521afaaf31b07a39f781ef9e59c7b5ecef44/selectolax-0.3.29-cp310-cp310-win32.whl", hash = "sha256:394d356ea611a7853c13c910a57c1a80a8356f9c920aa8168b3f8aaa62e433d8", size = 1702100, upload-time = "2025-04-30T15:15:55.833Z" }, + { url = "https://files.pythonhosted.org/packages/75/62/03350ed454fe26aef5580df498d45ace9f26ca6af1640ae681a6af1f5cdf/selectolax-0.3.29-cp310-cp310-win_amd64.whl", hash = "sha256:edd2760699c60dde7d847aebd81f02035f7bddcd0ad3db8e73326dfc84a2dc8f", size = 1807811, upload-time = "2025-04-30T15:15:57.243Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5d/ca72f7adddae4b2b128394a7559739a6a12c156d29b55968cfcfe07fac4d/selectolax-0.3.29-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6a1cd0518fa7656ea1683c4b2d3b5a98306753f364da9f673517847e1680a3e", size = 3649215, upload-time = "2025-04-30T15:15:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/08/c6/ca984f90b12fb10790cc56c2670f1b5f09884ed2f2012a219094b38cbcb4/selectolax-0.3.29-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3e5354d805dd76b4b38002f58e6ae2e7b429ac311bf3601992a6662d2bc86911", size = 2091848, upload-time = "2025-04-30T15:16:01.73Z" }, + { url = "https://files.pythonhosted.org/packages/98/7f/c999ae6d9bfbaac3e8dea3dbb5ca6bdf61c220828e80a6c339e89f9db777/selectolax-0.3.29-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7073e3bcdc60ebdb5f8777c79b465471ec000ab556134da4e00f037d3321a2ec", size = 5638593, upload-time = "2025-04-30T15:16:03.594Z" }, + { url = "https://files.pythonhosted.org/packages/d6/32/ffd89376a888c24ecaf01fcffc5fe97b82ae03ab163158f51a559f1ebad5/selectolax-0.3.29-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47587db7cef411d22f8224cf2926aacdb326c4c838d386035229f16ccc2d8d26", size = 5668207, upload-time = "2025-04-30T15:16:05.564Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5c/2de0c7b8be75ad52d44706c67946181b972f27641ab4f6a1f27f46d2a603/selectolax-0.3.29-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:21de62b5093b1cb6c5d4cab0bef5f708b9ee1483b640d42be9d955becfcd287a", size = 5276654, upload-time = "2025-04-30T15:16:07.143Z" }, + { url = "https://files.pythonhosted.org/packages/29/29/152bb745b24072d3eecd3b395c756e74763111b9bbd265604f5b96b9a1aa/selectolax-0.3.29-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:af5cd03298cd75cb0fbf712d6ae4f8aca9c13a226d2821ca82f51cc9b33b032f", size = 5543731, upload-time = "2025-04-30T15:16:09.733Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/df65baaf16ece393f9f1a7c55f015510634adbb163ce72adcafaddf5cf9c/selectolax-0.3.29-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3f58dca53d2d3dc18dfd2cb9210a5625f32598db24e3f857f5be58f21a8f3b88", size = 5275005, upload-time = "2025-04-30T15:16:11.958Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/e56fd6f9b3087947b812f3862df3265bf5e21396d9673d076e999b1086cf/selectolax-0.3.29-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0a6d8e02c6b9ba951d7b5a5dd2788a1d4bbdedc89782a4de165f1a87c4168ac", size = 5617441, upload-time = "2025-04-30T15:16:14.15Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/243049029bfc937b9f02faf4a4494e693575046414a475bf28ed9632b768/selectolax-0.3.29-cp311-cp311-win32.whl", hash = "sha256:912a1fc03157ebd066d8f59ae9ca2412ef95c7101a51590327c23071b02c97c7", size = 1701370, upload-time = "2025-04-30T15:16:16.339Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7f/baba8c5ce941c8cbd2dfb0c9f2253ba2d8c2d5d0fddda4f5a87eceb2484f/selectolax-0.3.29-cp311-cp311-win_amd64.whl", hash = "sha256:a3d44a295416b79815d2858ed4ccb71bf3b63087483a5d3705daa837c9dcf44d", size = 1808251, upload-time = "2025-04-30T15:16:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/ca4332eecc19124782f6f0d7cb28c331da2e9d9cf25287ba2b3b6a00cea1/selectolax-0.3.29-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6d3f373efd1db18ac9b2222de2668aaa366a1f0b560241eab128f3ca68e8add1", size = 3656166, upload-time = "2025-04-30T15:16:19.907Z" }, + { url = "https://files.pythonhosted.org/packages/b8/46/2dcae03a94f80f3e0d339c149de8110b5abe1230668b015fd338d9e71a27/selectolax-0.3.29-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:97b9971bb37b54ef4440134f22792d15c9ee12d890a526a7fe0b376502240143", size = 2095991, upload-time = "2025-04-30T15:16:21.654Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bd/95f15396e5f30898227d84a7ec6a39d9a9b34005f0e9f8f38e7fee21ab66/selectolax-0.3.29-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd99ff0f5a6c017c471635d4ee45b61d25f24689331e407147b2cf5e36892480", size = 5844493, upload-time = "2025-04-30T15:16:23.268Z" }, + { url = "https://files.pythonhosted.org/packages/36/25/64c60da9aec81f2992355b0a3ce00ea1ed99e6f5499868016d6972bd4948/selectolax-0.3.29-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8377c317bf1d5fd6ccc56dfb5a0928bbcbea3e800b7af54761cfbbb99dc94cb9", size = 5881062, upload-time = "2025-04-30T15:16:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/b6/81/94105217f91f7c6a98ac3164210cba0c6aa8da91cb85405292a6d70e39c3/selectolax-0.3.29-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5388c56456272b2c241fc1906db9cc993984cafdad936cb5e061e3af0c44144e", size = 5470368, upload-time = "2025-04-30T15:16:26.457Z" }, + { url = "https://files.pythonhosted.org/packages/51/6e/40bc259f13e5d3dd0bb8ddd1d55ef099244db2568ffb82fd9d489984d61a/selectolax-0.3.29-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9e4690894f406863e25ba49da27e1a6fda9bfc21b0b315c399d3093be080e81", size = 5693476, upload-time = "2025-04-30T15:16:28.386Z" }, + { url = "https://files.pythonhosted.org/packages/58/bd/2668ee1d5471ad88daf83ca484515ba46774fc9c951d6c4c0beffea89952/selectolax-0.3.29-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:deeab93386b6c9a75052515f5b9e7e3dd623c585871c0c2b3126970ff902603b", size = 5449747, upload-time = "2025-04-30T15:16:30.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b5/1c61839ae5af70a8291c643982a99f051b543df90b220b98db1b26bd4899/selectolax-0.3.29-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6abdd8357f1c105c1add01a9f0373511fa832548b2e2778b00a8ba2a4508d6ed", size = 5786843, upload-time = "2025-04-30T15:16:32.231Z" }, + { url = "https://files.pythonhosted.org/packages/67/08/ca42c100ab90168c123e6b521e38cb7618b697a693fdb77e42dabb0670fd/selectolax-0.3.29-cp312-cp312-win32.whl", hash = "sha256:9c969626b2295702076f50aac91e44c3bba639fa2e1a612bf6ae254bf29b4d57", size = 1697859, upload-time = "2025-04-30T15:16:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/5c/22/9524af51d950cc718bd4406f3bed05acbfcb321a4a308ec85b96ccdaa1ef/selectolax-0.3.29-cp312-cp312-win_amd64.whl", hash = "sha256:e7f4cc1b7ce9691559decfd5db7cc500e71a9f6ccfe76c054f284c184a1d1dc9", size = 1804145, upload-time = "2025-04-30T15:16:35.12Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a7/083a00aa9cb6bef0317baba4269841c366652558d77189275bed2da6aa81/selectolax-0.3.29-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e3112f05a34bf36d36ecc51520b1d98c4667b54a3f123dffef5072273e89a360", size = 3651407, upload-time = "2025-04-30T15:16:37.282Z" }, + { url = "https://files.pythonhosted.org/packages/7e/cd/6c89ac27961ef5f5e9b40eda0d0653b9c95c93485fb8a554bf093eac1c77/selectolax-0.3.29-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:38462ae369897f71da287f1282079c11f1b878b99a4d1d509d1116ce05226d88", size = 2092649, upload-time = "2025-04-30T15:16:38.817Z" }, + { url = "https://files.pythonhosted.org/packages/3e/12/82710124b7b52613fdb9d5c14494a41785eb83e1c93ec7e1d1814c2ce292/selectolax-0.3.29-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bdd1e63735f2fb8485fb6b9f4fe30d6c030930f438f46a4a62bd9886ab3c7fd9", size = 5821738, upload-time = "2025-04-30T15:16:40.747Z" }, + { url = "https://files.pythonhosted.org/packages/8b/08/8ceb3eb7fee9743026a4481fccb771f257c82b2c853a1a30271902234eab/selectolax-0.3.29-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea52e0c128e8e89f98ab0ccaabbc853677de5730729a3351da595976131b66e0", size = 5856069, upload-time = "2025-04-30T15:16:42.496Z" }, + { url = "https://files.pythonhosted.org/packages/47/6c/ec2b7aff0f6202e4157415d76bd588108cc518374bf53afa81c122691780/selectolax-0.3.29-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0933659b4250b91317ccd78167e6804389cdaf7ed86c5d034b058a550d23110f", size = 5443255, upload-time = "2025-04-30T15:16:44.083Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/d5fea46ff191d02c2380a779b119ea6799751b79fcddb2bb230b21b38fc5/selectolax-0.3.29-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0c9005e9089a6b0c6fb6a9f691ddbbb10a3a23ebeff54393980340f3dbcdb99", size = 5637529, upload-time = "2025-04-30T15:16:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/7f876a515f5af31f7b948cf10951be896fe6deeff2b9b713640c8ec82fd3/selectolax-0.3.29-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac940963c52f13cdf5d7266a979744949b660d367ce669efa073b557f6e09a18", size = 5379121, upload-time = "2025-04-30T15:16:47.909Z" }, + { url = "https://files.pythonhosted.org/packages/57/cb/7dc739a484b1a17ccf92a23dfe558ae615c232bd81e78a72049c25d1ff66/selectolax-0.3.29-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:484274f73839f9a143f4c13ce1b0a0123b5d64be22f967a1dc202a9a78687d67", size = 5727944, upload-time = "2025-04-30T15:16:49.52Z" }, + { url = "https://files.pythonhosted.org/packages/b7/09/95da4d2919d99a6090327390b84bc5440133196351e5e04c24cccda06cbb/selectolax-0.3.29-cp313-cp313-win32.whl", hash = "sha256:29e71fbd58b90d2920ef91a940680cb5331710fe397925ce9d10c3f2f086bf27", size = 1697529, upload-time = "2025-04-30T15:16:51.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/17/5a3951da22a4ad8f959088ddc370c68b28dad03190d91fcd137a52410fb9/selectolax-0.3.29-cp313-cp313-win_amd64.whl", hash = "sha256:e13befacff5f78102aa11465055ecb6d4b35f89663e36f271f2b506bcab14112", size = 1803334, upload-time = "2025-04-30T15:16:53.775Z" }, ] [[package]] name = "shellingham" version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] name = "sortedcontainers" version = "2.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] [[package]] name = "sse-starlette" -version = "1.6.1" +version = "3.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "starlette" }, + { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/88/0af7f586894cfe61bd212f33e571785c4570085711b24fb7445425a5eeb0/sse-starlette-1.6.1.tar.gz", hash = "sha256:6208af2bd7d0887c92f1379da14bd1f4db56bd1274cc5d36670c683d2aa1de6a", size = 14555 } +sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f7/499e5d0c181a52a205d5b0982fd71cf162d1e070c97dca90c60520bbf8bf/sse_starlette-1.6.1-py3-none-any.whl", hash = "sha256:d8f18f1c633e355afe61cc5e9c92eea85badcb8b2d56ec8cfb0a006994aa55da", size = 9553 }, + { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" }, ] [[package]] name = "starlette" -version = "0.27.0" +version = "0.49.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/68/559bed5484e746f1ab2ebbe22312f2c25ec62e4b534916d41a8c21147bf8/starlette-0.27.0.tar.gz", hash = "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75", size = 51394 } +sdist = { url = "https://files.pythonhosted.org/packages/1b/3f/507c21db33b66fb027a332f2cb3abbbe924cc3a79ced12f01ed8645955c9/starlette-0.49.1.tar.gz", hash = "sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb", size = 2654703, upload-time = "2025-10-28T17:34:10.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f8/e2cca22387965584a409795913b774235752be4176d276714e15e1a58884/starlette-0.27.0-py3-none-any.whl", hash = "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91", size = 66978 }, + { url = "https://files.pythonhosted.org/packages/51/da/545b75d420bb23b5d494b0517757b351963e974e79933f01e05c929f20a6/starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875", size = 74175, upload-time = "2025-10-28T17:34:09.13Z" }, ] [[package]] @@ -1411,53 +2443,53 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "webencodings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085 } +sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610 }, + { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, ] [[package]] name = "tomli" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] [[package]] name = "trio" -version = "0.26.2" +version = "0.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -1468,14 +2500,14 @@ dependencies = [ { name = "sniffio" }, { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9a/03/ab0e9509be0c6465e2773768ec25ee0cb8053c0b91471ab3854bbf2294b2/trio-0.26.2.tar.gz", hash = "sha256:0346c3852c15e5c7d40ea15972c4805689ef2cb8b5206f794c9c19450119f3a4", size = 561156 } +sdist = { url = "https://files.pythonhosted.org/packages/76/8f/c6e36dd11201e2a565977d8b13f0b027ba4593c1a80bed5185489178e257/trio-0.31.0.tar.gz", hash = "sha256:f71d551ccaa79d0cb73017a33ef3264fde8335728eb4c6391451fe5d253a9d5b", size = 605825, upload-time = "2025-09-09T15:17:15.242Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/70/efa56ce2271c44a7f4f43533a0477e6854a0948e9f7b76491de1fd3be7c9/trio-0.26.2-py3-none-any.whl", hash = "sha256:c5237e8133eb0a1d72f09a971a55c28ebe69e351c783fc64bc37db8db8bbe1d0", size = 475996 }, + { url = "https://files.pythonhosted.org/packages/31/5b/94237a3485620dbff9741df02ff6d8acaa5fdec67d81ab3f62e4d8511bf7/trio-0.31.0-py3-none-any.whl", hash = "sha256:b5d14cd6293d79298b49c3485ffd9c07e3ce03a6da8c7dfbe0cb3dd7dc9a4774", size = 512679, upload-time = "2025-09-09T15:17:13.821Z" }, ] [[package]] name = "typer" -version = "0.12.4" +version = "0.17.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1483,139 +2515,151 @@ dependencies = [ { name = "shellingham" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d4/f7/f174a1cae84848ae8b27170a96187b91937b743f0580ff968078fe16930a/typer-0.12.4.tar.gz", hash = "sha256:c9c1613ed6a166162705b3347b8d10b661ccc5d95692654d0fb628118f2c34e6", size = 97945 } +sdist = { url = "https://files.pythonhosted.org/packages/92/e8/2a73ccf9874ec4c7638f172efc8972ceab13a0e3480b389d6ed822f7a822/typer-0.17.4.tar.gz", hash = "sha256:b77dc07d849312fd2bb5e7f20a7af8985c7ec360c45b051ed5412f64d8dc1580", size = 103734, upload-time = "2025-09-05T18:14:40.746Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/cc/15083dcde1252a663398b1b2a173637a3ec65adadfb95137dc95df1e6adc/typer-0.12.4-py3-none-any.whl", hash = "sha256:819aa03699f438397e876aa12b0d63766864ecba1b579092cc9fe35d886e34b6", size = 47402 }, + { url = "https://files.pythonhosted.org/packages/93/72/6b3e70d32e89a5cbb6a4513726c1ae8762165b027af569289e19ec08edd8/typer-0.17.4-py3-none-any.whl", hash = "sha256:015534a6edaa450e7007eba705d5c18c3349dcea50a6ad79a5ed530967575824", size = 46643, upload-time = "2025-09-05T18:14:39.166Z" }, ] [[package]] name = "typing-extensions" -version = "4.12.2" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] [[package]] name = "urllib3" -version = "2.3.0" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] [[package]] name = "uvicorn" -version = "0.30.0" +version = "0.35.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/f7/4ad826703a49b320a4adf2470fdd2a3481ea13f4460cb615ad12c75be003/uvicorn-0.30.0.tar.gz", hash = "sha256:f678dec4fa3a39706bbf49b9ec5fc40049d42418716cea52b53f07828a60aa37", size = 42560 } +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/a1/d57e38417a8dabb22df02b6aebc209dc73485792e6c5620e501547133d0b/uvicorn-0.30.0-py3-none-any.whl", hash = "sha256:78fa0b5f56abb8562024a59041caeb555c86e48d0efdd23c3fe7de7a4075bdab", size = 62388 }, + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, ] [[package]] name = "watchdog" version = "6.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390 }, - { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389 }, - { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020 }, - { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393 }, - { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392 }, - { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019 }, - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471 }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449 }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054 }, - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480 }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451 }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057 }, - { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902 }, - { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380 }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] [[package]] name = "webencodings" version = "0.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 }, + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, ] [[package]] name = "websockets" version = "15.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423 }, - { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080 }, - { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329 }, - { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312 }, - { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319 }, - { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631 }, - { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016 }, - { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426 }, - { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360 }, - { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388 }, - { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830 }, - { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423 }, - { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082 }, - { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330 }, - { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878 }, - { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883 }, - { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252 }, - { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521 }, - { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958 }, - { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918 }, - { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388 }, - { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828 }, - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, - { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109 }, - { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343 }, - { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599 }, - { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207 }, - { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155 }, - { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884 }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ]